<?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: Bryan Clark</title>
    <description>The latest articles on DEV Community by Bryan Clark (@clarkbw--).</description>
    <link>https://dev.to/clarkbw--</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%2F3999099%2F8d584f7a-5880-42f7-b8ea-4a89d82ed3ac.jpg</url>
      <title>DEV Community: Bryan Clark</title>
      <link>https://dev.to/clarkbw--</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/clarkbw--"/>
    <language>en</language>
    <item>
      <title>Read Puppeteer console errors off the argument handles, not msg.text()</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Mon, 21 Sep 2026 16:12:51 +0000</pubDate>
      <link>https://dev.to/clarkbw--/read-puppeteer-console-errors-off-the-argument-handles-not-msgtext-29cd</link>
      <guid>https://dev.to/clarkbw--/read-puppeteer-console-errors-off-the-argument-handles-not-msgtext-29cd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Puppeteer's &lt;code&gt;msg.text()&lt;/code&gt; is a &lt;em&gt;rendering&lt;/em&gt; of a console argument,&lt;br&gt;
not the argument. Log an object and you get &lt;code&gt;[object Object]&lt;/code&gt; — or, from a&lt;br&gt;
minified bundle, &lt;code&gt;[object Ae]&lt;/code&gt; — with every URL and field inside it gone. If&lt;br&gt;
anything downstream classifies console errors by substring, read the arguments&lt;br&gt;
instead: &lt;code&gt;msg.args()&lt;/code&gt; → &lt;code&gt;arg.evaluate(…)&lt;/code&gt;. That's async, so collection has to&lt;br&gt;
be separated from judgement. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Fconsole-arg-resolution.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Fconsole-arg-resolution.svg" alt="Two lanes showing the same maplibre console error: read through msg.text it renders as a minified class name with the tile host lost, so the noise filter cannot classify it and CI goes red; read through msg.args and arg.evaluate it arrives as real error text naming the tile host, gets classified as expected offline noise, and CI goes green." width="880" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;&lt;code&gt;slackwater-web&lt;/code&gt;&lt;/a&gt; is an&lt;br&gt;
offline-first tide and current PWA, so a big share of its Puppeteer smoke suite&lt;br&gt;
runs with the network deliberately cut and asserts that nothing broke. "Nothing&lt;br&gt;
broke" means: no &lt;code&gt;pageerror&lt;/code&gt;, and no &lt;code&gt;console.error&lt;/code&gt; that isn't &lt;em&gt;expected&lt;/em&gt;&lt;br&gt;
offline noise. Expected noise is a real category here — with the network down,&lt;br&gt;
Chrome itself logs a resource-load failure for every external fetch the app&lt;br&gt;
makes and honestly handles, and none of those are app errors.&lt;/p&gt;

&lt;p&gt;So there's a classifier. It matches on the hosts we know are external:&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;isChsFetchNoise&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;span class="k"&gt;return &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="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IWLS_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;        &lt;span class="c1"&gt;// tide prediction API&lt;/span&gt;
    &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SEASCAPE_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;    &lt;span class="c1"&gt;// bathymetry tiles&lt;/span&gt;
    &lt;span class="sr"&gt;/blocked by CORS policy/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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="o"&gt;||&lt;/span&gt;
    &lt;span class="sr"&gt;/Failed to load resource: net::ERR_&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;FAILED|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_REFUSED|TIMED_OUT|ADDRESS_UNREACHABLE&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every clause reads a &lt;strong&gt;string&lt;/strong&gt;. That's the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The map check went red in CI and only in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;SMOKE FAILED: /map offline reload reported errors:
console.error: [object Ae]
console.error: [object Ae]
console.error: [object Ae]
console.error: [object Ae]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines, no information in any of them. &lt;code&gt;Ae&lt;/code&gt; is a two-letter token out of a&lt;br&gt;
minified bundle. There's no host to look up, no stack, no status code — nothing&lt;br&gt;
to decide whether this is the map politely failing to reach a tile server with&lt;br&gt;
the network cut (fine, expected, that's the whole point of the check) or the&lt;br&gt;
app actually breaking (not fine).&lt;/p&gt;

&lt;p&gt;The listener that produced them was the obvious one, copied six times across&lt;br&gt;
the suite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;console&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="nx"&gt;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;isChsFetchNoise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`console.error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Diagnosis: &lt;code&gt;text()&lt;/code&gt; is a description, not the value
&lt;/h2&gt;

&lt;p&gt;A console argument lives in the browser. Puppeteer holds a &lt;code&gt;JSHandle&lt;/code&gt; — a&lt;br&gt;
&lt;em&gt;reference&lt;/em&gt; into the page — and &lt;code&gt;ConsoleMessage.text()&lt;/code&gt; gives you the browser's&lt;br&gt;
own short description of each argument joined together. For a string that&lt;br&gt;
description is the string. For an object it's whatever Chrome would print as a&lt;br&gt;
one-line label.&lt;/p&gt;

&lt;p&gt;Fifteen lines of Puppeteer will show you the difference. Log four things and&lt;br&gt;
read each one both ways:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;console&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viaEval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;args&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&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;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;viaEval&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ae&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;404&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Be&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not Found: &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&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="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ae&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://tiles.example.org/a.pbf&lt;/span&gt;&lt;span class="dl"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://tiles.example.org/b.pbf&lt;/span&gt;&lt;span class="dl"&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to fetch&lt;/span&gt;&lt;span class="dl"&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="na"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://tiles.example.org/x&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;Output, on Chrome 151 with &lt;code&gt;puppeteer-core&lt;/code&gt; 25.3.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"[object Ae]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"viaEval"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"[object Object]"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Be: Not Found: https://tiles.example.org/b.pbf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"viaEval"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Error: Not Found: https://tiles.example.org/b.pbf"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Error: Failed to fetch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"viaEval"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"Error: Failed to fetch"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"[object Object]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"viaEval"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"[object Object]"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line one is the CI failure, reproduced in a file with no app in it. A class&lt;br&gt;
instance Chrome doesn't describe as an &lt;code&gt;Error&lt;/code&gt; renders as&lt;br&gt;
&lt;code&gt;[object &amp;lt;ClassName&amp;gt;]&lt;/code&gt;, and after minification the class name is &lt;code&gt;Ae&lt;/code&gt;. The&lt;br&gt;
&lt;code&gt;url&lt;/code&gt; property that was sitting right there on the object never appears in the&lt;br&gt;
rendered text at all — so &lt;code&gt;isChsFetchNoise&lt;/code&gt; had literally nothing to match on,&lt;br&gt;
and a fetch failure we &lt;em&gt;expect&lt;/em&gt; under a cut network got counted as an app error.&lt;/p&gt;

&lt;p&gt;Note line two as well: even for an &lt;code&gt;Error&lt;/code&gt; subclass the prefix is the minified&lt;br&gt;
&lt;strong&gt;constructor&lt;/strong&gt; name, not &lt;code&gt;err.name&lt;/code&gt;. &lt;code&gt;text()&lt;/code&gt; is a debugging convenience. It&lt;br&gt;
was never a data source, and nothing in its shape tells you when it has dropped&lt;br&gt;
the field you're keying on.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why it was green locally
&lt;/h3&gt;

&lt;p&gt;The tempting explanation is a browser difference. It wasn't one. CI resolved&lt;br&gt;
Chrome-for-Testing &lt;strong&gt;151.0.7922.71&lt;/strong&gt;; the laptop that ran the same suite green&lt;br&gt;
was on &lt;strong&gt;151.0.7922.109&lt;/strong&gt;. Same major, same minor, three days apart.&lt;/p&gt;

&lt;p&gt;The real reason is duller and much more common: the assertion ran before the&lt;br&gt;
errors existed.&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;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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;mapErrors&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="k"&gt;throw&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="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// green&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MapLibre puts its canvas in the DOM as soon as it has a GL context — well&lt;br&gt;
before it has finished trying, and failing, to fetch tiles and glyphs over a&lt;br&gt;
network that isn't there. On a fast local machine the check sampled an empty&lt;br&gt;
array roughly two seconds early and passed. On a slower CI runner the failures&lt;br&gt;
landed inside the window, got collected, and were unreadable. One bug hid the&lt;br&gt;
other: the suite had to get &lt;em&gt;slower&lt;/em&gt; before it could show me it was also&lt;br&gt;
blind.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Add the missing host to the allowlist.&lt;/strong&gt; Can't — that's the point. The host&lt;br&gt;
is inside an object the classifier never sees. There is no string to add.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Widen the filter to swallow the unreadable shape.&lt;/strong&gt;&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="c1"&gt;// no&lt;/span&gt;
&lt;span class="o"&gt;/^&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nx"&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;test&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="o"&gt;||&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version that ships. It's green, it's one line, and it converts&lt;br&gt;
every future object-shaped console error in the app — including real ones —&lt;br&gt;
into permitted noise. A filter whose match is "I couldn't read this" isn't a&lt;br&gt;
filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach for &lt;code&gt;jsonValue()&lt;/code&gt;&lt;/strong&gt;, which is the answer most search results give:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;args&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&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;jsonValue&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 better than &lt;code&gt;text()&lt;/code&gt; and still wrong for this. &lt;code&gt;jsonValue()&lt;/code&gt; serialises,&lt;br&gt;
so an &lt;code&gt;Error&lt;/code&gt; comes back as its enumerable own properties only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"AJAXError"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://tiles.openwaters.io/12/34/56.pbf"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;message&lt;/code&gt; — which is where MapLibre puts the status and the failing URL —&lt;br&gt;
isn't an enumerable own property, so it's gone. On a plain &lt;code&gt;new Error("boom")&lt;/code&gt;&lt;br&gt;
you get &lt;code&gt;{}&lt;/code&gt;. It happens to carry the URL here because that error also stores it&lt;br&gt;
as a field; rely on that and the next library breaks you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only assert &lt;code&gt;pageerror&lt;/code&gt; and stop filtering console errors at all.&lt;/strong&gt; Genuinely&lt;br&gt;
tempting, and it would have stayed green forever. It also deletes the assertion&lt;br&gt;
that catches an app that logs its way through a broken offline path without&lt;br&gt;
throwing — which is exactly the failure mode an offline-first PWA has.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Resolve each argument &lt;em&gt;in the page&lt;/em&gt;, where it's still a real object:&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;watchErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pageerror&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="nx"&gt;err&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;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&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;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pageerror&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;raw&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="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
  &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;console&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="nx"&gt;msg&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;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&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;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;args&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;arg&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;arg&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&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;o&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;// Falls back to the rendered text when the page closed before the&lt;/span&gt;
        &lt;span class="c1"&gt;// handles could be read, or when there are no arguments at all&lt;/span&gt;
        &lt;span class="c1"&gt;// (browser-level "Failed to load resource" lines).&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;parts&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;parts&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="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;raw&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="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;console.error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filterable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&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;seen&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;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filterable&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isChsFetchNoise&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;Read that same failing line now and it says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: AJAXError: Not Found (404): https://tiles.openwaters.io/…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— which &lt;code&gt;isChsFetchNoise&lt;/code&gt; classifies correctly, because the host is finally in&lt;br&gt;
the string.&lt;/p&gt;

&lt;p&gt;Three things in there are load-bearing beyond the &lt;code&gt;evaluate&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pageerror&lt;/code&gt; is never filterable.&lt;/strong&gt; An uncaught exception is always a
failure, whatever it says. Only console errors get classified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both &lt;code&gt;.catch()&lt;/code&gt;es fall back to &lt;code&gt;msg.text()&lt;/code&gt;.&lt;/strong&gt; A handle can't be read after
its page closes, and browser-level lines like &lt;code&gt;Failed to load resource:
net::ERR_FAILED&lt;/code&gt; arrive with no arguments at all — &lt;code&gt;msg.text()&lt;/code&gt; is the
correct source for those, and it's the reason that regex clause still earns
its place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The return value is a &lt;code&gt;settle()&lt;/code&gt;, not an array.&lt;/strong&gt; Resolving a handle is a
round trip to the browser, so the listener can't append synchronously any
more. Collection and judgement are now two separate moments, and every call
site has to await the second one:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settleMapErrors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapPage&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// The canvas appears well before the map has finished failing its external&lt;/span&gt;
&lt;span class="c1"&gt;// fetches, so asserting the instant it exists passed for the wrong reason —&lt;/span&gt;
&lt;span class="c1"&gt;// the errors simply hadn't been logged yet. Settle first, then judge.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&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;mapErrors&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;settleMapErrors&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;mapErrors&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="k"&gt;throw&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;`/map offline reload reported errors:\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mapErrors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The async fix forced the shape that fixes the race. That's a nice accident, not&lt;br&gt;
a design — but it's the reason to prefer a collector object over an array you&lt;br&gt;
push into: an array tempts you to read it at the wrong instant, and a &lt;code&gt;settle()&lt;/code&gt;&lt;br&gt;
can't be read at all until you've awaited it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The ordering the offline blocks need
&lt;/h2&gt;

&lt;p&gt;Same suite, adjacent trap. Cutting the network with CDP and then asserting&lt;br&gt;
requires the steps in one specific order, and two of the six exist purely&lt;br&gt;
because getting them wrong produces a &lt;strong&gt;green&lt;/strong&gt; run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Foffline-block-ordering.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Foffline-block-ordering.svg" alt="Six ordered steps for an offline emulation check: load online until the service worker controls the page, cut the network over CDP, prove the cut with a real fetch because a connectivity flag is not evidence, attach the console listeners only after that proof fetch, reload and wait for the canvas, then settle every pending handle before judging." width="880" height="452"&gt;&lt;/a&gt;&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="c1"&gt;// 2. cut it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cdp&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCDPSession&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;cdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Network.enable&lt;/span&gt;&lt;span class="dl"&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;cdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Network.emulateNetworkConditions&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="na"&gt;offline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;downloadThroughput&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="na"&gt;uploadThroughput&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="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. prove it — a real request to an origin that must not resolve&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;networkState&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;random&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UP&lt;/span&gt;&lt;span class="dl"&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;networkState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;down&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="k"&gt;throw&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;`network emulation did not take effect: fetch reported "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;networkState&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 4. only NOW attach the listeners&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settleErrors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&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;Don't accept &lt;code&gt;navigator.onLine&lt;/code&gt; as the proof.&lt;/strong&gt; It answers "does this device&lt;br&gt;
have a network interface", never "can I reach anything" — a captive portal&lt;br&gt;
returns &lt;code&gt;true&lt;/code&gt; all day. Under CDP emulation it's worse than merely unhelpful:&lt;br&gt;
whether the flag follows the emulated state is a browser-and-transport detail&lt;br&gt;
that has changed across versions and setups, and has read &lt;code&gt;true&lt;/code&gt; on a cut&lt;br&gt;
network in this project before. On the combination I re-measured today&lt;br&gt;
(Chrome 151, &lt;code&gt;puppeteer-core&lt;/code&gt; 25.3.0) it reported &lt;code&gt;false&lt;/code&gt; correctly, both via&lt;br&gt;
CDP and via &lt;code&gt;page.setOfflineMode(true)&lt;/code&gt; — which is exactly why it's a bad&lt;br&gt;
oracle. A signal that's right on your machine, this week, and silently wrong on&lt;br&gt;
the runner is the same failure class as everything else in this post. A fetch&lt;br&gt;
that must reject is evidence. A flag is a claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attach the listeners &lt;em&gt;after&lt;/em&gt; the proof fetch, not before.&lt;/strong&gt; The proof fetch is&lt;br&gt;
supposed to fail — that's its job — and Chrome logs its own resource-load error&lt;br&gt;
for it regardless of the &lt;code&gt;.catch()&lt;/code&gt; on the JS side. Attach a console listener&lt;br&gt;
first and your very first collected "app error" is the test's own probe. It'll&lt;br&gt;
be the top line of a failure report about something else entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bonus finding: a third origin nobody had written down
&lt;/h2&gt;

&lt;p&gt;Once the errors were legible, they immediately said something new. The&lt;br&gt;
allowlist had two external hosts on it — the tide API and the bathymetry tile&lt;br&gt;
host. The real map was hitting &lt;strong&gt;three&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Fthree-origins.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpuppeteer-console-message-text-object-object-class-name-msg-args-jshandle-evaluate-maplibre-ajaxerror-navigator-online-emulatenetworkconditions-offline%2Fthree-origins.svg" alt="The map style sheet fetched from the bathymetry tile host points its glyphs field at a third-party font server, so the label layer fetches glyph files from an origin on no allowlist, while the tile host and the tide API host were both already allowed." width="880" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A MapLibre style sheet has a &lt;code&gt;glyphs&lt;/code&gt; field, and the vendor style we load points&lt;br&gt;
it at MapLibre's public demo font server — a different origin from the one&lt;br&gt;
serving the style. Add a label layer and you're fetching glyph files from a host&lt;br&gt;
nobody in the project ever chose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GLYPH_HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demotiles.maplibre.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// NOT the tile host&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real finding, not just test bookkeeping: an offline-first app was&lt;br&gt;
quietly depending on a third party's demo infrastructure to draw place names.&lt;br&gt;
It had been true for weeks. Nobody knew, because for weeks the only evidence&lt;br&gt;
was a line reading &lt;code&gt;[object Ae]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more, in the same batch: when the network is cut hard enough that no&lt;br&gt;
response comes back at all, MapLibre re-wraps the rejection as a bare&lt;br&gt;
&lt;code&gt;Error: Failed to fetch&lt;/code&gt; carrying no URL — its &lt;code&gt;AJAXError&lt;/code&gt;, the one that &lt;em&gt;does&lt;/em&gt;&lt;br&gt;
name the host, only exists once a response actually arrived. There is nothing&lt;br&gt;
left to attribute it to, so that one gets an anchored exact match rather than a&lt;br&gt;
substring:&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="o"&gt;/^&lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Failed&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;fetch$&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&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="o"&gt;||&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anchored, so &lt;code&gt;Error: Failed to fetch https://…&lt;/code&gt; from anywhere else still fails&lt;br&gt;
the smoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;The general rule is narrower than "console errors are unreliable":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never classify on a rendering.&lt;/strong&gt; &lt;code&gt;msg.text()&lt;/code&gt;, a log line, a formatted table,&lt;br&gt;
a CLI's pretty output — every one of them is a lossy projection chosen for a&lt;br&gt;
human reader, and every one of them can change between versions without&lt;br&gt;
breaking a single type. If a decision depends on a field, get the field. Here&lt;br&gt;
the field was one &lt;code&gt;arg.evaluate()&lt;/code&gt; away the whole time.&lt;/p&gt;

&lt;p&gt;Two traps sit next to it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A filter that can't parse its input must fail, not pass.&lt;/strong&gt; The one-line
regex that swallows &lt;code&gt;[object Ae]&lt;/code&gt; is available at every step of this story and
it's the wrong move every time. If your classifier gets something it can't
read, that's a red test, not a matched rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waiting for the first sign of life is not waiting.&lt;/strong&gt; &lt;code&gt;waitForSelector&lt;/code&gt; on
the artifact that appears &lt;em&gt;earliest&lt;/em&gt; is the most common way to build a check
that samples before the system has finished. Wait for the last thing, or
settle over a window and judge afterwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the meta-lesson: this suite was green for two months. It went red only when&lt;br&gt;
a slower machine let the errors land inside the assertion window — the fastest&lt;br&gt;
way to find out a check is blind is to make it slower.&lt;/p&gt;




&lt;p&gt;I'm building the software for an all-electric charter catamaran mostly in&lt;br&gt;
public — SignalK plugins, MCP servers, and this offline tide app that has to&lt;br&gt;
work with no signal in the Salish Sea. The smoke suite above lives in&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web/blob/main/scripts/smoke.mjs" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/smoke.mjs&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/slackwater-web/commit/32bfe09" rel="noopener noreferrer"&gt;&lt;code&gt;32bfe09&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;Porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; — what it looks like when a check does examine the result · &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt; — the same silence, one layer out&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>testing</category>
      <category>ci</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Adding a type column to a shared registry breaks every consumer's filter</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 20 Sep 2026 05:27:10 +0000</pubDate>
      <link>https://dev.to/clarkbw--/adding-a-type-column-to-a-shared-registry-breaks-every-consumers-filter-1d06</link>
      <guid>https://dev.to/clarkbw--/adding-a-type-column-to-a-shared-registry-breaks-every-consumers-filter-1d06</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A shared data package grew an optional &lt;code&gt;kind: "tide"&lt;/code&gt; field, with absent meaning &lt;code&gt;"current"&lt;/code&gt; for backwards compatibility. Every consumer that &lt;em&gt;enumerated&lt;/em&gt; the registry had a hand-rolled "which rows are gates" filter written before that field existed, so the new rows swept straight in — ten &lt;code&gt;no live id for Victoria&lt;/code&gt;-style throws per poll cycle on the boat server, and one false &lt;code&gt;name drift?&lt;/code&gt; warning per build in another repo. The real fix isn't a better filter in each consumer. Jump to the fix: export the selection from the package that owns the data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Ffalse-log-lines-before-after.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Ffalse-log-lines-before-after.svg" alt="False log lines fall from ten per cycle to zero in both consumers, but the hand-rolled patch in chs-constituents left one false name-drift warning per build until the shared currentGates selector removed it." width="880" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Two symptoms, three weeks apart, in two repos that share one dependency.&lt;/p&gt;

&lt;p&gt;The SignalK currents plugin, on the boat server, on &lt;strong&gt;every poll cycle&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no live id for Tofino
no live id for Victoria
no live id for Sooke
no live id for Fulford Harbour
no live id for Port Renfrew
no live id for Campbell River
no live id for Point Atkinson
no live id for Vancouver
no live id for Port Alberni
no live id for Owen Bay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the harmonic-constituent bundler, on &lt;strong&gt;every build&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;registry gate chs-malibu-rapids found no live IWLS station (name drift?)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is fatal, which is exactly why both ran for weeks. The throw drops the&lt;br&gt;
station, so &lt;code&gt;/currents&lt;/code&gt; still served the right 20 gates; the bundler still emitted&lt;br&gt;
a valid bundle. The cost is noise — a wasted fetch attempt per station per cycle,&lt;br&gt;
real station errors buried under fake ones, and a drift warning that now fires on&lt;br&gt;
a station that &lt;em&gt;cannot&lt;/em&gt; drift, training you to ignore it when a real CHS rename&lt;br&gt;
lands.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;The dependency is &lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/station-corrections&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
— station identity (name, position, provider) for the Salish Sea tide and current&lt;br&gt;
stations, published as a plain JSON artifact so non-JS consumers can read it&lt;br&gt;
without npm. It was &lt;strong&gt;currents-only by accident&lt;/strong&gt;: current gates were simply the&lt;br&gt;
first data whose identity we owned, not a decision anyone made.&lt;/p&gt;

&lt;p&gt;Then it grew a second class. CHS tide reference ports arrived so a gate could name&lt;br&gt;
its companion port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;chs-owen-bay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Owen Bay&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Sonora Island&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;50.310&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-125.223&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chs&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tide&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema change was written to be maximally compatible:&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="c1"&gt;// kind is optional, not required: the 19 existing gates predate the&lt;/span&gt;
&lt;span class="c1"&gt;// distinction and an absent kind reads as "current", so the resolver&lt;/span&gt;
&lt;span class="c1"&gt;// applies that default (one line) rather than backfilling every entry.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional-with-a-default is the right call for the &lt;em&gt;data&lt;/em&gt;. For consumers it is a&lt;br&gt;
trap, because their filters are written in terms of what the registry happened to&lt;br&gt;
hold on the day they were written. Both of them said the same thing:&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;// signalk-currents&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&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;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* → a station we will fetch a live current series for */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// chs-constituents&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// → a name overlay we will match against the live IWLS station list&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both answer "which rows are current gates?" with "the ones from this provider."&lt;br&gt;
True when written. Silently false the moment the registry grows a class.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Fregistry-classes-and-filters.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Fregistry-classes-and-filters.svg" alt="The shared registry holds four row classes; each consumer hand-rolled a filter that excluded one new class and let the other leak in, so signalk-currents swept in ten tide ports and chs-constituents swept in the derived gate." width="880" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The registry today is 31 rows in four shapes: 19 plain current gates (&lt;code&gt;kind&lt;/code&gt;&lt;br&gt;
absent), 10 tide ports (&lt;code&gt;kind: tide&lt;/code&gt;), 1 NOAA gate, and one oddity that matters —&lt;br&gt;
a &lt;strong&gt;derived&lt;/strong&gt; gate. Malibu Rapids is a genuine current gate that CHS publishes no&lt;br&gt;
current station for at all; its slack comes from Point Atkinson's high/low water&lt;br&gt;
plus a lag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;chs-malibu-rapids&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Malibu Rapids&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;50.1626&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-123.8515&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chs&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;current&lt;/span&gt;          &lt;span class="c1"&gt;# still a gate…&lt;/span&gt;
  &lt;span class="na"&gt;derived&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;               &lt;span class="c1"&gt;# …but there is no series of its own to fetch&lt;/span&gt;
    &lt;span class="na"&gt;reference&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chs-point-atkinson&lt;/span&gt;
    &lt;span class="na"&gt;hwLagMinutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
    &lt;span class="na"&gt;lwLagMinutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;35&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So there are two ways to be in the registry and not be something you can fetch a&lt;br&gt;
live current series for — a wrong &lt;code&gt;kind&lt;/code&gt;, and a right &lt;code&gt;kind&lt;/code&gt; with a &lt;code&gt;derived&lt;/code&gt;&lt;br&gt;
block. Each consumer discovered exactly one of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Exclude the new kind, in the consumer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;chs-constituents&lt;/code&gt;, the day the tide ports landed. Ten false drift warnings per&lt;br&gt;
build, so:&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;// Tide reference ports (2.2.0+) have no current series; carrying them&lt;/span&gt;
&lt;span class="c1"&gt;// into the overlay makes the no-live-station drift warning fire 10x/build.&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Warnings per build: 10 → 1. The survivor is Malibu. This filter has no opinion&lt;br&gt;
about &lt;code&gt;derived&lt;/code&gt;, because on the day it was written Malibu did not exist yet — it&lt;br&gt;
was added to the registry the &lt;em&gt;next day&lt;/em&gt;. A gate that is derived &lt;em&gt;precisely&lt;br&gt;
because&lt;/em&gt; CHS publishes no station for it can never match a live IWLS station, and&lt;br&gt;
the code warns on exactly that condition. One false warning, every build, for&lt;br&gt;
three weeks.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Exclude derived gates, in the &lt;em&gt;other&lt;/em&gt; consumer
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;signalk-currents&lt;/code&gt;, the next day, against the other new class — Malibu would have&lt;br&gt;
been swept into the fetch list and thrown every cycle:&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="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;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;derived&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact complement of fix #1. Two consumers of one registry, 24 hours apart,&lt;br&gt;
each excluding the class that had just arrived in front of it, neither noticing&lt;br&gt;
they were the same bug. &lt;code&gt;signalk-currents&lt;/code&gt; still swept in all ten tide ports —&lt;br&gt;
nearly three more weeks of ten throws per poll cycle on the boat server before&lt;br&gt;
anyone read that log.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Denylist the tide ports
&lt;/h3&gt;

&lt;p&gt;The obvious patch, shipped as 0.11.1:&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="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;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;derived&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tide&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct today, and the wrong shape. &lt;strong&gt;The bug was the registry growing a kind this&lt;br&gt;
repo had never seen — and &lt;code&gt;!== 'tide'&lt;/code&gt; re-acquires that bug the next time it&lt;br&gt;
grows one.&lt;/strong&gt; The regression test writes down the shape:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skips a kind it has never seen, rather than assuming it is a gate&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;future&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;chs-somewhere&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Somewhere&lt;/span&gt;&lt;span class="dl"&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="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wave&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;registryChsStations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;future&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The denylist fails it. An allowlist passes:&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="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;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;derived&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&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="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Two correct filters, in two repos
&lt;/h3&gt;

&lt;p&gt;Now both consumers agreed. The policy still lived in two places, expressed in two&lt;br&gt;
different styles, in two languages' worth of idiom — and the next reader of that&lt;br&gt;
registry would write a third copy from whatever it happened to hold that month.&lt;br&gt;
Four fixes in, nothing had changed about &lt;em&gt;why&lt;/em&gt; it kept breaking.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move the selection into the package that owns the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currentGates&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;registry&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundledRegistry&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;includeDerived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&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;gates&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;registry&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;provider&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// allowlist&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;includeDerived&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;derived&lt;/span&gt; &lt;span class="o"&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;gates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;record&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;gates&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;Both consumers delete their filters and ask:&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;// signalk-currents&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;currentGates&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;registry&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// chs-constituents&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;currentGates&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;registry&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;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;gates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind&lt;/code&gt; is the registry's own editorial classification. Only the package that adds&lt;br&gt;
a class knows what the existing consumers meant by "gate" — so it should be the&lt;br&gt;
one that decides, once, instead of every reader rediscovering the split from a&lt;br&gt;
production symptom.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it matters, and the traps nearby
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An optional column with a default is a breaking change for enumerators.&lt;/strong&gt; Semver&lt;br&gt;
calls it a minor: nothing was removed, no field changed meaning, every old row&lt;br&gt;
still parses. But every consumer that iterates rows carries an implicit&lt;br&gt;
&lt;code&gt;WHERE&lt;/code&gt; clause it never wrote down, and a new row type silently violates it. If&lt;br&gt;
you ship a shared data artifact, "we added a type column" belongs in the release&lt;br&gt;
notes next to the breaking changes, whatever the version number says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A denylist re-acquires the bug it just fixed.&lt;/strong&gt; &lt;code&gt;kind !== 'tide'&lt;/code&gt; encodes "the&lt;br&gt;
classes I know about today." The failure mode being patched &lt;em&gt;was&lt;/em&gt; not knowing&lt;br&gt;
about a class. Prefer &lt;code&gt;kind is current&lt;/code&gt;, and prove it with a test that passes a&lt;br&gt;
kind you invented.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type the shared selector by what it reads, not by your record.&lt;/strong&gt; The first&lt;br&gt;
release declared &lt;code&gt;registry?: Registry&lt;/code&gt; — the package's own full station type.&lt;br&gt;
&lt;code&gt;chs-constituents&lt;/code&gt; builds its overlay on a deliberately narrow &lt;code&gt;{name, provider,&lt;br&gt;
kind}&lt;/code&gt; record whose fixtures omit &lt;code&gt;position&lt;/code&gt; to prove the overlay reads no&lt;br&gt;
position. Adopting the selector meant widening a type to satisfy a signature&lt;br&gt;
rather than a caller, so the follow-up release made it generic over the caller's&lt;br&gt;
shape:&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;/** The three fields currentGates reads. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;GateSelectable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;provider&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;kind&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;derived&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="p"&gt;}&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;currentGates&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="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;GateSelectable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RegistryStation&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;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;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="nl"&gt;provider&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;includeDerived&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="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;T&lt;/span&gt;&lt;span class="o"&gt;&amp;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 shared selector that forces its callers to widen their own types is not&lt;br&gt;
adoptable, and un-adopted selectors leave the hand-rolled filters in place. Note&lt;br&gt;
that &lt;code&gt;tsc&lt;/code&gt; is the only thing that catches this class of regression, so it's worth&lt;br&gt;
a compile-only surface test that instantiates the narrow shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only the enumerators are at risk.&lt;/strong&gt; A third consumer reads the same registry and&lt;br&gt;
never broke: &lt;code&gt;currents-mcp&lt;/code&gt; looks stations up by key, because each vault pass&lt;br&gt;
names its own &lt;code&gt;station:&lt;/code&gt; id. A new row class is invisible to code that asks for a&lt;br&gt;
row by name.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Fenumerate-vs-lookup.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fshared-registry-kind-field-breaks-consumer-filters-allowlist-vs-denylist-no-live-id-name-drift-warning%2Fenumerate-vs-lookup.svg" alt="A new row class in a shared registry only breaks the consumers that enumerate every row; a consumer that looks a row up by key never sees the new class at all." width="880" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A warning that fires on a station missing by definition is worse than no&lt;br&gt;
warning.&lt;/strong&gt; The &lt;code&gt;name drift?&lt;/code&gt; check exists to catch a real CHS rename detaching a&lt;br&gt;
curated gate from its live station — a failure that would otherwise be silent. One&lt;br&gt;
guaranteed false positive per build is enough to teach you to scroll past it. The&lt;br&gt;
value of that last fix isn't the log line it removes; it's the signal it restores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;All of this runs on a boat: a SignalK server serving tidal-current predictions to&lt;br&gt;
an agent that answers questions about when a pass goes slack. The registry, the&lt;br&gt;
plugin, and the constituent bundler are public —&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;station-corrections&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;signalk-currents&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;chs-constituents&lt;/a&gt; — and&lt;br&gt;
&lt;code&gt;currentGates()&lt;/code&gt; is now the only place any of them decides what a gate is.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-07-31-offline-tidal-current-predictions-signalk-harmonic-constituents-noaa-harcon-neaps-fallback-slack-timing-rapids%20"&gt;Offline tidal currents from harmonic constituents&lt;/a&gt; — what the gates in this registry are actually for — and &lt;a href="https://dev.to%20post_url%202026-07-18-noaa-co-ops-currents-api-harcon-empty-constituents-currbin-currents-predictions-not-available-user-agent-404%20"&gt;two false walls in NOAA's currents API&lt;/a&gt;, the other half of where station identity comes from.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>datamodel</category>
      <category>npm</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Four ways a delivery-health alarm silently doesn't work</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 16 Sep 2026 16:04:50 +0000</pubDate>
      <link>https://dev.to/clarkbw--/four-ways-a-delivery-health-alarm-silently-doesnt-work-1bd9</link>
      <guid>https://dev.to/clarkbw--/four-ways-a-delivery-health-alarm-silently-doesnt-work-1bd9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Four independent bugs in one small delivery-health check. The headline: &lt;strong&gt;mqtt.js only invokes the &lt;code&gt;publish&lt;/code&gt; callback on PUBACK&lt;/strong&gt;, so during a broker outage it fires neither success nor failure and a consecutive-failure counter never moves — the monitor sleeps through the exact outage it was written to report. Fix: record a failure yourself when &lt;code&gt;client.connected === false&lt;/code&gt;, then publish anyway. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Ffour-blind-spots.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Ffour-blind-spots.svg" alt="Diagram of a delivery-health path — lane outcome, failure counter, raised guard, notifications tree, MQTT publish, dashboard and voice — with four marked points where the alarm silently stops: the callback that never fires, the loop guard placed before the publish, the restart that clears the in-memory set but not the tree, and a visual-only alarm that cannot page." width="880" height="548"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Five weeks ago I published &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt;. The argument: a notification pipeline is exercised only when something is already wrong, so it needs its own pulse — count consecutive delivery failures, raise a &lt;code&gt;deliveryFailed&lt;/code&gt; notification past a threshold, and surface it on a channel that doesn't depend on the lane that's down.&lt;/p&gt;

&lt;p&gt;I still believe every word of it. This post is what happened when I re-implemented that same pattern in the plugin that replaced the relay, shipped it, and then read the whole branch back with fresh eyes. &lt;strong&gt;The health check was broken four independent ways.&lt;/strong&gt; None of them were in the alarm-routing paths — the sirens worked fine. All four were in the layer whose entire job is to tell me when the sirens don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem: the watcher looked right and did nothing
&lt;/h2&gt;

&lt;p&gt;The successor is &lt;a href="https://github.com/sailingnaturali/signalk-notification-router" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-notification-router&lt;/code&gt;&lt;/a&gt; — one subscription to &lt;code&gt;notifications.*&lt;/code&gt;, three outputs: MQTT (which the dashboard and the voice agent read), a Telegram siren for &lt;code&gt;alarm&lt;/code&gt;/&lt;code&gt;emergency&lt;/code&gt;, and an agent webhook for &lt;code&gt;alert&lt;/code&gt;/&lt;code&gt;warn&lt;/code&gt;. Delivery health is tracked &lt;strong&gt;per lane&lt;/strong&gt;, so MQTT dying doesn't claim Telegram is failing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failures&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;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// lane -&amp;gt; consecutive failure count&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raised&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="c1"&gt;// lanes currently carrying a deliveryFailed alarm&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raised&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;lane&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;raised&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setDeliveryFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;)&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="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="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;,&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;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;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;failureThreshold&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;raised&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;lane&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;raised&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setDeliveryFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the July post's design, faithfully. It shipped in 1.0.0 with 53 passing tests. Every test I had thought to write was green — and the reason they were green is that &lt;strong&gt;each of the four bugs lives in a seam between two components I had tested separately&lt;/strong&gt;: the counter and the MQTT client, the notification tree and the publish path, the plugin lifecycle and the in-memory mirror of the tree, the alarm and the human.&lt;/p&gt;

&lt;p&gt;A whole-branch review after shipping is what found them. Not a bug report — nothing reported it, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: four places one alarm can stop
&lt;/h2&gt;

&lt;p&gt;Walk the path an alarm has to travel and the failures line up in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lane send outcome → recordResult() → raised guard → notifications tree
                                   → MQTT publish → dashboard + voice → human
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;The outcome never arrives, so the counter never moves. (mqtt.js)&lt;/li&gt;
&lt;li&gt;The counter moves and the tree is written, but the row never leaves the tree. (the loop guard)&lt;/li&gt;
&lt;li&gt;The tree is written, and can then never be un-written. (the restart latch)&lt;/li&gt;
&lt;li&gt;The row reaches the dashboard, which is the &lt;em&gt;only&lt;/em&gt; place it can ever reach. (visual-only)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one alone makes the health check useless. I had all four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind spot 1: mqtt.js only calls back on PUBACK
&lt;/h2&gt;

&lt;p&gt;The MQTT lane's outcome came from the publish callback:&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;publishMqtt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mqttClient&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="nx"&gt;mqttClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&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;envelope&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;qos&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="na"&gt;retain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mqtt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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;This reads like every callback in Node: fires with an error, or fires without one. It isn't. From the &lt;a href="https://github.com/mqttjs/MQTT.js/blob/main/README.md#publish" rel="noopener noreferrer"&gt;mqtt.js README&lt;/a&gt;, on &lt;code&gt;client.publish(topic, message, [options], [callback])&lt;/code&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;callback&lt;/code&gt; - &lt;code&gt;function (err, packet)&lt;/code&gt;, fired when the QoS handling completes, or at the next tick if QoS 0. An error occurs if client is disconnecting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"When the QoS handling completes" means &lt;strong&gt;on PUBACK&lt;/strong&gt; at QoS 1 (PUBCOMP at QoS 2). The client is &lt;code&gt;mqtt@5&lt;/code&gt;; in &lt;code&gt;lib/client.js&lt;/code&gt;, an offline publish never reaches the wire at all:&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="c1"&gt;// _sendPacket()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_storePacket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cbStorePut&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// queue it, and return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;_storePacket&lt;/code&gt; puts the QoS-1 packet in the outgoing store, where the callback waits for an ack that cannot arrive until the broker comes back. The callback isn't errored. It isn't dropped. It's &lt;strong&gt;deferred, indefinitely&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So during a broker outage the MQTT lane records neither a success nor a failure. &lt;code&gt;failures.get('mqtt')&lt;/code&gt; stays at &lt;code&gt;0&lt;/code&gt;, the threshold is never crossed, and &lt;code&gt;deliveryFailed.mqtt&lt;/code&gt; cannot raise. A health counter that only counts &lt;em&gt;completed&lt;/em&gt; deliveries measures nothing during an outage, because an outage produces no completions.&lt;/p&gt;

&lt;p&gt;This is the July post's own lesson biting from the other end. There I wrote that a quiet boat produces no sends, so no failures, so a false all-clear. Here the boat is noisy — alarms are firing, publishes are being called — and it &lt;em&gt;still&lt;/em&gt; produces no failures, because failure was defined as "the callback said so" and the callback is on the far side of the broken thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I tried
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Await the publish instead.&lt;/strong&gt; mqtt v5 has &lt;code&gt;publishAsync&lt;/code&gt;, and a promise reads better than a callback:&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;try&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;mqttClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;qos&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="na"&gt;retain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mqtt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;catch &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="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mqtt&lt;/span&gt;&lt;span class="dl"&gt;'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical semantics. The promise settles on the same PUBACK, so it simply hangs instead of not calling back. Prettier, equally blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Race the callback against a timer.&lt;/strong&gt; There is no per-publish timeout option, so this means hand-rolling one per message — and then holding the bag when the deferred callback &lt;em&gt;does&lt;/em&gt; fire on reconnect, twenty minutes later, against a message you already recorded as failed. Double-counting a recovering lane into a fresh alarm is worse than the bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Drop to QoS 0 with &lt;code&gt;queueQoSZero: false&lt;/code&gt;.&lt;/strong&gt; This genuinely does produce an immediate error callback while offline — &lt;code&gt;_storePacket&lt;/code&gt;'s last branch fires &lt;code&gt;cb(new Error('No connection to broker'))&lt;/code&gt;. It also throws away the thing QoS 1 buys: real alarms queue and flush on reconnect. Trading alarm delivery for alarm &lt;em&gt;observability&lt;/em&gt; is exactly backwards.&lt;/p&gt;

&lt;p&gt;The fix is not to make the callback trustworthy. It's to stop asking the callback a question it can't answer, and ask the client directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Fpuback-timeline.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Fpuback-timeline.svg" alt="Two timelines of an mqtt.js QoS-1 publish. With the broker up, publish is followed by PUBACK and the callback fires, so the health counter records a result. With the broker down the packet is queued in the outgoing store, no PUBACK arrives, the callback never fires, and the failure counter stays frozen at zero so deliveryFailed can never raise." width="880" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind spot 2: the loop guard sat one line too early
&lt;/h2&gt;

&lt;p&gt;The July post's hardest-won rule was: never route the health alarm through the failing path. The router obeyed it with an early &lt;code&gt;continue&lt;/code&gt; at the top of the delta handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.&lt;/span&gt;&lt;span class="dl"&gt;'&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="c1"&gt;// Never route our own delivery-path alarms back out — they must not&lt;/span&gt;
&lt;span class="c1"&gt;// loop through the very lane that is failing.&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SELF_PREFIX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct intent, wrong line. Everything downstream of that &lt;code&gt;continue&lt;/code&gt; includes the MQTT publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;publishMqtt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;   &lt;span class="c1"&gt;// ← never reached for self rows&lt;/span&gt;
&lt;span class="nx"&gt;newlyActive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                             &lt;span class="c1"&gt;// ← the push lanes (correctly skipped)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MQTT is not a push lane here. It is the &lt;strong&gt;only&lt;/strong&gt; path from the SignalK notifications tree out to the dashboard and the voice agent — the two "independent channels" the whole design leans on. Skipping it meant &lt;code&gt;deliveryFailed.mqtt&lt;/code&gt; (and &lt;code&gt;.telegram&lt;/code&gt;, and &lt;code&gt;.hook&lt;/code&gt;) reached exactly one audience: somebody already logged into the SignalK admin UI, looking at the notifications tree, which is not a thing anyone does on a boat at 02:00.&lt;/p&gt;

&lt;p&gt;The alarm was real, correct, well-tested, and invisible.&lt;/p&gt;

&lt;p&gt;The test that covered this was green, and its name is the giveaway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;✔ the plugin never routes its own deliveryFailed alarm
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asserted the alarm didn't reach Telegram or the webhook. It never asserted the alarm reached &lt;em&gt;anything&lt;/em&gt;. A negative test with no positive counterpart will happily pass on a dead system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind spot 3: an admin-UI config save latched the alarm forever
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;raised&lt;/code&gt; is an in-memory mirror of what's currently &lt;code&gt;alert&lt;/code&gt; in the notifications tree. &lt;code&gt;plugin.start&lt;/code&gt; reset it along with everything else, which looks like ordinary hygiene:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;raised&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// ← this one&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SignalK notifications tree is server state; it survives a plugin restart. The in-memory &lt;code&gt;Set&lt;/code&gt; does not. And &lt;strong&gt;saving config in the SignalK admin UI calls &lt;code&gt;stop()&lt;/code&gt; then &lt;code&gt;start()&lt;/code&gt;&lt;/strong&gt; — no server restart, no page reload, just a save button. Sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. the mqtt lane fails 3× → raised = {mqtt}, tree shows deliveryFailed.mqtt = alert
2. operator saves any config change → SignalK calls stop(), then start()
3. start() clears raised     → raised = {},  tree still shows alert
4. the broker comes back, a publish succeeds → recordResult('mqtt', true)
5. `if (raised.has(lane))` is false → setDeliveryFailed(lane, false) never runs
6. tree shows alert forever — until the whole SignalK server restarts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A latched-on alarm on a recovered lane is arguably worse than no alarm: it trains you to ignore the one indicator whose entire value is that it's normally quiet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I tried:&lt;/strong&gt; rebuilding &lt;code&gt;raised&lt;/code&gt; on start by reading the tree back with &lt;code&gt;app.getSelfPath('notifications.notificationRouter.deliveryFailed…')&lt;/code&gt;. It works, and it's more "correct" in that it re-derives state from the source of truth. It's also more code, one more API dependency, and one more thing to get wrong at startup — to fix a bug whose cause was clearing a set that had no business being cleared. &lt;code&gt;failures&lt;/code&gt; &lt;em&gt;should&lt;/em&gt; reset (a stale failure streak shouldn't count toward a fresh threshold). &lt;code&gt;raised&lt;/code&gt; shouldn't. Deleting one line beats adding ten.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Frestart-latch.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmqtt-js-publish-callback-not-called-broker-offline-puback-signalk-delivery-health-alarm-deliveryfailed-plugin-restart-latched-config-save-stop-start-visual-only%2Frestart-latch.svg" alt="Timeline of the latched alarm, showing the notifications tree stuck at alert while the in-memory raised set is emptied by a config save, so the lane's recovery never clears the tree." width="880" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blind spot 4: visual-only, on purpose
&lt;/h2&gt;

&lt;p&gt;The fourth isn't a bug. It's the design constraint that makes the other three matter, and it doesn't go away when they're fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&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;visual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;      &lt;span class="c1"&gt;// never ['sound'], never a push&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; delivery path failing — notifications are not reaching it`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timestamp&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routing keys on the notification's own &lt;code&gt;method&lt;/code&gt; array, so &lt;code&gt;['visual']&lt;/code&gt; is what stops the health alarm from being classified into a push lane and looping straight back through the lane that's failing. It's deliberate and it's right.&lt;/p&gt;

&lt;p&gt;It also means the health alarm &lt;strong&gt;can never page anybody.&lt;/strong&gt; It is a light on a panel. If the Telegram lane is down and nobody opens the dashboard, the fact that Telegram is down is displayed, correctly, to an empty room. The July post said to surface the alarm on an independent channel; what it didn't say clearly enough is that &lt;em&gt;independent&lt;/em&gt; and &lt;em&gt;reachable&lt;/em&gt; are two different properties, and a self-hosted stack usually only has enough independent paths to guarantee one of them.&lt;/p&gt;

&lt;p&gt;I don't have a fix for this one. I have a boundary: the health alarm reports lane failures to whatever is watching, and something outside this plugin — a phone, a person, a second provider — has to be the thing that watches. Naming the residual risk beats pretending the fourth channel exists.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1 — ask the client, not the callback.&lt;/strong&gt; One line before the publish, and still publish, so QoS-1 alarms queue and flush on reconnect:&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;publishMqtt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mqttClient&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;// mqtt.js only calls the publish callback on PUBACK, so an unreachable&lt;/span&gt;
  &lt;span class="c1"&gt;// broker would otherwise record neither success nor failure — the health&lt;/span&gt;
  &lt;span class="c1"&gt;// counter would sit silent through the exact outage it exists to report.&lt;/span&gt;
  &lt;span class="c1"&gt;// Still publish: qos-1 messages queue and flush on reconnect.&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;mqttClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connected&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="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mqtt&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;mqttClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&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;envelope&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;qos&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="na"&gt;retain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mqtt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&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;catch &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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;app&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="s2"&gt;`mqtt publish callback error: &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="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;=== false&lt;/code&gt;, not &lt;code&gt;!mqttClient.connected&lt;/code&gt; — a test double or a client mid-handshake without the property shouldn't be read as offline. And the callback is invoked directly by mqtt.js, not through a promise chain, so a throw inside it takes down the process rather than losing one outcome.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 — move the guard past the publish.&lt;/strong&gt; Self rows publish to MQTT like any other row; only push routing is skipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isSelf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SELF_PREFIX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="nf"&gt;publishMqtt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;buildEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="c1"&gt;// Our own delivery-path alarms still publish to MQTT (the only path from the&lt;/span&gt;
&lt;span class="c1"&gt;// notifications tree to voice/dashboard) but are never routed back out through&lt;/span&gt;
&lt;span class="c1"&gt;// classify/route — that would loop through the very lane that is failing.&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;isSelf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;newlyActive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Safe by two independent mechanisms, not one: &lt;code&gt;method: ['visual']&lt;/code&gt; means &lt;code&gt;classify()&lt;/code&gt; can't assign it a lane anyway, and an MQTT failure can't re-raise past the &lt;code&gt;raised&lt;/code&gt; guard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 — don't clear the mirror of state you didn't clear.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Do NOT clear `raised`: the notifications tree survives a plugin restart, so&lt;/span&gt;
&lt;span class="c1"&gt;// forgetting a raised lane here means the next success never clears the tree.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4 — no code.&lt;/strong&gt; Documented as a boundary, in the README and above.&lt;/p&gt;

&lt;p&gt;Every fix TDD'd, red before green, 53 → 63 tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;✔ an unreachable MQTT broker still records failures and raises deliveryFailed.mqtt (Finding 3)
✔ a connected test double without a `connected` property is treated as connected
✔ the delivery-health alarm itself reaches MQTT (Finding 1)
✔ the plugin never routes its own deliveryFailed alarm, but still publishes it to MQTT
✔ a raised alarm survives a restart and still clears on recovery (Finding 2)
✔ a throwing app.error does not produce an unhandled rejection, and later deliveries still work (Finding 5)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the second test name in that renamed pair. &lt;code&gt;the plugin never routes its own deliveryFailed alarm&lt;/code&gt; became &lt;code&gt;…, but still publishes it to MQTT&lt;/code&gt;. The bug was in the clause that wasn't there.&lt;/p&gt;

&lt;p&gt;Shipped as &lt;a href="https://www.npmjs.com/package/@sailingnaturali/signalk-notification-router" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/signalk-notification-router&lt;/code&gt; 1.2.0&lt;/a&gt;. Anyone who ran 1.0.0–1.1.0 is affected by the first two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your monitor's failure modes are correlated with the thing it monitors.&lt;/strong&gt; This is the whole post in one line. Three of the four bugs are the same mistake wearing different clothes: the health check was built out of the same components as the delivery path, so the outage took both down together. The publish callback is unavailable precisely when the broker is; the health alarm's only exit is the transport whose failure it reports. Independence has to be &lt;em&gt;designed in&lt;/em&gt; and then &lt;em&gt;checked&lt;/em&gt;, or you've built a smoke detector wired to the same circuit as the stove.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Fires on success or error" is an assumption, not a contract.&lt;/strong&gt; Check what your client actually promises. mqtt.js says "when the QoS handling completes", and completion requires the broker. Two caveats if you copy this fix: with &lt;code&gt;queueQoSZero: false&lt;/code&gt; an offline QoS-0 publish &lt;em&gt;does&lt;/em&gt; error back immediately (a different branch entirely), and &lt;a href="https://github.com/mqttjs/MQTT.js/issues/1561" rel="noopener noreferrer"&gt;MQTT.js#1561&lt;/a&gt; documents a race where a QoS-0 callback can be dropped rather than deferred — closed as not planned. Don't state the deferral as a universal guarantee; state it for QoS 1 at default settings, which is what it is.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The plugin lifecycle is a failure mode.&lt;/strong&gt; Saving config in the SignalK admin UI is a &lt;code&gt;stop()&lt;/code&gt; + &lt;code&gt;start()&lt;/code&gt;. Any in-memory structure that mirrors server-side state — the notifications tree, a resource provider's cache, a subscription list — has to survive that, or reset &lt;em&gt;both&lt;/em&gt; sides. Half a reset is a latch. (&lt;code&gt;plugin.start&lt;/code&gt; is now also self-guarding: it calls &lt;code&gt;plugin.stop()&lt;/code&gt; first, so a &lt;code&gt;start()&lt;/code&gt; without a preceding &lt;code&gt;stop()&lt;/code&gt; can't leave two live subscriptions and a double siren.)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A negative test passes on a dead system.&lt;/strong&gt; &lt;code&gt;never routes its own alarm&lt;/code&gt; was true of the correct implementation, of the broken one, and of a plugin that does nothing at all. Every "X must not reach Y" assertion needs its "X must reach Z" partner in the same test, or the day Z breaks, nothing turns red.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ship, then read the whole branch back.&lt;/strong&gt; No user reported any of this; the failure mode of a broken monitor is silence. The four were found by re-reading a shipped branch end to end, with the specific question "how does each of these paths &lt;em&gt;not&lt;/em&gt; work?" — which is a different exercise from code review, and the only one that catches a bug whose symptom is nothing happening.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This is the alerting layer of an all-electric charter catamaran, where the difference between a silent alarm and a working one is the entire value of the system. The router — the health check, the four fixes, and the tests — is MIT and open: &lt;a href="https://github.com/sailingnaturali/signalk-notification-router" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-notification-router&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt; — the health check this post re-implements and breaks; &lt;a href="https://dev.to%20post_url%202026-06-05-signalk-ntfy-push-notifications-to-phone-zero-dependency-relay%20"&gt;a zero-dependency SignalK relay&lt;/a&gt; — the plugin this router replaced; &lt;a href="https://dev.to%20post_url%202026-07-11-nmea-2000-paradox-alarm-fatigue-signalk-open-source-severity-notifications-plain-language-voice-alerts-vendor-lock-in%20"&gt;the NMEA 2000 alarm-fatigue problem&lt;/a&gt; — why the notification's own &lt;code&gt;method&lt;/code&gt; array decides routing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>mqtt</category>
      <category>notifications</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>A nearest-neighbour matcher turned a timing lag into a fake direction bug</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 11 Sep 2026 15:40:45 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-nearest-neighbour-matcher-turned-a-timing-lag-into-a-fake-direction-bug-49k6</link>
      <guid>https://dev.to/clarkbw--/a-nearest-neighbour-matcher-turned-a-timing-lag-into-a-fake-direction-bug-49k6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — If your comparison against golden data matches each expected event&lt;br&gt;
to the &lt;em&gt;nearest computed event of any kind&lt;/em&gt; and then asserts the kind, you have&lt;br&gt;
coupled two independent error dimensions. A timing lag will surface as a&lt;br&gt;
wrong-label failure and your oracle will confidently blame the wrong thing.&lt;br&gt;
Match same-kind-to-same-kind for timing, and test direction separately at the&lt;br&gt;
expected timestamp. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We validate our offline tidal-current engine against the hydrographic&lt;br&gt;
authority's own published current predictions: for each published maximum flood&lt;br&gt;
or maximum ebb, does the model produce the same event, at about the same time,&lt;br&gt;
at about the same speed, and pointing the same way? A validation run over the&lt;br&gt;
Canadian stations came back with two stations flagged as having a &lt;strong&gt;reversed&lt;br&gt;
flood axis&lt;/strong&gt; — the model claiming flood where the authority says ebb. That is&lt;br&gt;
the one failure you cannot ship. Both stations were quarantined.&lt;/p&gt;

&lt;p&gt;Both stations were fine. The matcher was broken.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Ftwo-verdicts.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Ftwo-verdicts.svg" alt="Two comparison idioms give two verdicts on the same golden data: nearest-event-of-any-kind plus a kind assertion quarantines Tillicum Bridge and Calamity Point as reversed-axis stations, while same-kind matching plus a sign-of-velocity direction test measures 0 wrong signs out of 19 and 0 out of 24 and ships both." width="880" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is about the &lt;em&gt;matcher&lt;/em&gt; — how you compare model output against golden&lt;br&gt;
data. It is the sequel to&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;porting a tide engine to Swift with the original as the test oracle&lt;/a&gt;,&lt;br&gt;
which is about where golden data comes from. Same test suite; the bug is in the&lt;br&gt;
other half.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: a label-flip failure that wasn't
&lt;/h2&gt;

&lt;p&gt;The failing assertion looked like this, and its output is what got two stations&lt;br&gt;
quarantined:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✕ maxEbb at 2026-07-14T14:12:00Z: engine labeled slack (0.03 kn)
✕ maxEbb at 2026-07-15T02:41:00Z: engine labeled maxFlood (0.61 kn)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that at face value and there is only one conclusion available: the engine&lt;br&gt;
is mislabelling direction. For a current station, direction comes from a single&lt;br&gt;
piece of metadata — the &lt;strong&gt;flood direction&lt;/strong&gt;, the compass bearing the channel&lt;br&gt;
floods toward, which the model projects the fitted velocity onto. Get that&lt;br&gt;
bearing wrong by 180° and every flood becomes an ebb. It is a real, documented&lt;br&gt;
failure mode: the published &lt;code&gt;floodDirection&lt;/code&gt; is occasionally wrong for the&lt;br&gt;
actual channel axis. So "the engine labelled ebb as flood" reads as &lt;em&gt;"the flood&lt;br&gt;
axis is reversed"&lt;/em&gt;, which is a data-quality problem at the station, not a bug in&lt;br&gt;
the model — and the response is to quarantine the station rather than ship a&lt;br&gt;
current that runs backwards.&lt;/p&gt;

&lt;p&gt;Here is the harness that produced it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"slack"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Nearest computed event by TIME (avoids cross-cycle mis-match at weak,&lt;/span&gt;
    &lt;span class="c1"&gt;// mostly-single-direction stations), then require kind + tolerance.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;#require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                     &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="s"&gt;"no computed event near &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CurrentEventKind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"maxFlood"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;maxFlood&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxEbb&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;timeErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;speedErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(m.kind == kind, "\(e.kind) at \(e.time): engine labeled \(m.kind) (\(m.speed) kn)")&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(timeErr &amp;lt; 20, "\(e.kind) at \(e.time): time off \(timeErr) min (phase field wrong?)")&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(speedErr &amp;lt; 0.3, "\(e.kind) at \(e.time): speed \(m.speed) vs \(e.speed)")&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines of idiom that every golden-data comparison in the world has written&lt;br&gt;
at some point: &lt;strong&gt;find the nearest computed thing, then assert everything about&lt;br&gt;
it.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis: the matcher couples two independent errors
&lt;/h2&gt;

&lt;p&gt;Two properties of the model are being measured here, and they are independent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Timing&lt;/strong&gt; — is the model's max ebb at the same minute as the published max ebb?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direction&lt;/strong&gt; — does the model think the water is going the same way?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The nearest-neighbour match couples them, because the &lt;em&gt;search&lt;/em&gt; is over time and&lt;br&gt;
the &lt;em&gt;assertion&lt;/em&gt; is over kind. The candidate list from &lt;code&gt;station.events()&lt;/code&gt; contains&lt;br&gt;
slacks as well as extrema. So the match picks the wrong event as soon as the&lt;br&gt;
timing error exceeds roughly half the spacing to the neighbouring event of a&lt;br&gt;
different kind — and then the kind assertion fires on an event that was never&lt;br&gt;
supposed to be the counterpart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Fnearest-any-kind-vs-same-kind.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Fnearest-any-kind-vs-same-kind.svg" alt="At a weak station whose prediction runs late, the nearest computed event of any kind to a published max ebb is a slack 17 minutes away, which the old assertion reported as a wrong-kind label flip, while the nearest same-kind event is the model's own max ebb 22 minutes away and the modelled velocity at the published time is minus 0.9 knots, correctly negative for an ebb." width="880" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At a strong, cleanly semidiurnal station you never hit that condition: slack to&lt;br&gt;
max is about three hours, so a matcher would need to be ninety minutes wrong&lt;br&gt;
before it grabbed the wrong event. But the stations that got quarantined are&lt;br&gt;
weak and slow-reversing — narrow, shallow, tidally awkward places where the&lt;br&gt;
velocity loafs around zero, the model's own timing error runs to tens of&lt;br&gt;
minutes, and the extrema are broad and low. That is exactly where the gap&lt;br&gt;
collapses and the nearest event of any kind stops being the counterpart.&lt;/p&gt;

&lt;p&gt;So the failure output is a lie in a very specific way. The engine is not&lt;br&gt;
labelling anything wrong. It is &lt;em&gt;late&lt;/em&gt;, and the matcher converts lateness into a&lt;br&gt;
label-flip report. Two error dimensions, one nearest-neighbour search, and the&lt;br&gt;
oracle can no longer tell you which one is broken.&lt;/p&gt;

&lt;p&gt;The sign test settles it in one line and is not fooled by timing at all: ask&lt;br&gt;
what the modelled velocity actually &lt;em&gt;is&lt;/em&gt; at the moment the authority reports a&lt;br&gt;
maximum ebb. At both quarantined stations it is negative at every single one —&lt;br&gt;
&lt;strong&gt;0 wrong signs out of 19, and 0 out of 24&lt;/strong&gt;. Directionally perfect. The&lt;br&gt;
quarantine was manufactured by the comparison idiom.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1: believe the failure and quarantine the stations.&lt;/strong&gt; This is the&lt;br&gt;
attempt that cost the most, because it &lt;em&gt;worked&lt;/em&gt; in the sense that the test went&lt;br&gt;
green and the bad data stopped shipping. Two stations dropped out of the offline&lt;br&gt;
bundle with a note saying the flood axis was reversed, and downstream consumers&lt;br&gt;
grew a stale line in their docs saying those stations "are known to flip."&lt;br&gt;
Nothing about the quarantine looked wrong until someone asked why two stations&lt;br&gt;
in completely different bodies of water, from different fits, would both have a&lt;br&gt;
bad flood bearing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: match nearest-by-time &lt;em&gt;first&lt;/em&gt;, then require the kind.&lt;/strong&gt; This is the&lt;br&gt;
idiom above, and it deserves a proper Chesterton's-fence reading, because the&lt;br&gt;
comment sitting on top of it is not decoration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Nearest computed event by TIME (avoids cross-cycle mis-match at weak,&lt;/span&gt;
&lt;span class="c1"&gt;// mostly-single-direction stations), then require kind + tolerance.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is describing a &lt;em&gt;real&lt;/em&gt; earlier bug. If you filter to the same kind&lt;br&gt;
and then take the nearest, at a weak, mostly-single-direction station you can&lt;br&gt;
match across a cycle boundary — grabbing yesterday's ebb, producing a wildly&lt;br&gt;
wrong timing number. Matching by time first was a defence against that. It just&lt;br&gt;
traded a loud, obviously-wrong timing number for a quiet, plausible direction&lt;br&gt;
error, which is the worse trade: an absurd timing number gets investigated, a&lt;br&gt;
credible-looking label flip gets believed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: drop slacks from the candidate list.&lt;/strong&gt; The obvious minimal patch —&lt;br&gt;
if the nearest event is a slack, stop offering slacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* nearest by time */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It removes the most common wrong match and none of the mechanism. The opposite&lt;br&gt;
extremum is still in the list, and at a station running far enough late, the&lt;br&gt;
nearest non-slack event to a published max ebb is the model's max &lt;em&gt;flood&lt;/em&gt;. Same&lt;br&gt;
coupled search, same false direction report, now with fewer symptoms — which&lt;br&gt;
mostly means it takes longer to notice.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Stop asking one question and reading two answers out of it. Match same-kind for&lt;br&gt;
timing; test direction at the golden timestamp, where timing cannot interfere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Nearest computed event of the SAME KIND as the golden event.&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;nearestSameKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                             &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CurrentEventKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
             &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&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="c1"&gt;/// Sign of the station's velocity at the golden extremum time — the sound&lt;/span&gt;
&lt;span class="c1"&gt;/// direction test (flood positive, ebb negative).&lt;/span&gt;
&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;signedSpeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;station&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CurrentStation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;station&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;speeds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addingTimeInterval&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="nv"&gt;step&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;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then the loop asserts the two things separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CurrentEventKind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"maxFlood"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;maxFlood&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxEbb&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;#require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nearestSameKind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"no computed &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; near &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;timeErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;speedErr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signedSpeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;station&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="cp"&gt;#expect(e.kind == "maxFlood" ? v &amp;gt; 0 : v &amp;lt; 0,&lt;/span&gt;
        &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; at &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;: modelled velocity &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; kn has the wrong sign"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="cp"&gt;#expect(timeErr &amp;lt; 20, "\(e.kind) at \(e.time): time off \(timeErr) min (phase field wrong?)")&lt;/span&gt;
&lt;span class="cp"&gt;#expect(speedErr &amp;lt; 0.3, "\(e.kind) at \(e.time): speed \(m.speed) vs \(e.speed)")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three assertions, three questions, no shared search. A late station now reports&lt;br&gt;
one number — &lt;code&gt;time off 22.4 min&lt;/code&gt; — and a reversed station reports a wrong sign.&lt;br&gt;
Neither can impersonate the other.&lt;/p&gt;

&lt;p&gt;The same separation, in the TypeScript harness that builds the offline bundle:&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;for &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;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;observed&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;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&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;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;Infinity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;predicted&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;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// TIMING: same kind only&lt;/span&gt;
    &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;best&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MATCH_WINDOW_MIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;);&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;wrongSign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &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;event&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;extrema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                      &lt;span class="c1"&gt;// DIRECTION: sign at the golden time&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;velocityAt&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maxFlood&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;modelled&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;maxEbb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;modelled&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;wrongSign&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it matters: same-kind matching is the &lt;em&gt;safer&lt;/em&gt; test, not the laxer one
&lt;/h2&gt;

&lt;p&gt;The instinct when you delete &lt;code&gt;#expect(m.kind == kind)&lt;/code&gt; is that you have just&lt;br&gt;
removed the check that catches a reversed axis. The opposite is true, and the&lt;br&gt;
argument is worth internalising because it is what makes the fix safe to ship.&lt;/p&gt;

&lt;p&gt;A genuinely reversed flood axis does not flip &lt;em&gt;some&lt;/em&gt; labels. It flips every&lt;br&gt;
event on the station, so the model's max ebbs land where the true max &lt;em&gt;floods&lt;/em&gt;&lt;br&gt;
are — &lt;strong&gt;about half a tidal cycle away, roughly six hours&lt;/strong&gt;. Under same-kind&lt;br&gt;
matching, the nearest computed max ebb to every published max ebb is therefore&lt;br&gt;
six hours out, and blows through a ±20-minute timing gate at &lt;em&gt;every single&lt;br&gt;
extremum&lt;/em&gt; on the station.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Freversed-axis-half-cycle.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fnearest-neighbour-matching-couples-errors-validation-matcher-wrong-kind-test-oracle-false-positive-direction-error-golden-data-comparison%2Freversed-axis-half-cycle.svg" alt="Same-kind matching still catches a genuinely reversed flood axis: a station that merely runs late puts its ebbs about 18 minutes from the published ebbs and passes the timing gate, while a reversed axis puts the nearest same-kind ebb about six hours away, half a tidal cycle, so the timing gate fails at every extremum instead of flipping a few labels." width="880" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the reversed axis is caught &lt;em&gt;twice&lt;/em&gt; — once loudly by the sign test, once&lt;br&gt;
structurally by the timing gate — and a merely-late station is caught by&lt;br&gt;
neither. Coupled, the two failures were indistinguishable. Separated, they can't&lt;br&gt;
be confused: a real flip is systematic, a false one is occasional. The bundle&lt;br&gt;
builder now encodes exactly that, quarantining only on systematic disagreement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A reversed flood axis quarantines a station outright… The test is the sign of
the modelled velocity at CHS's own extremum times, and the bar is systematic
disagreement (≥ 60% of extrema): a genuinely reversed axis is wrong at nearly
every peak. Occasional wrong signs are timing error at a weak station, not a flip.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The transferable version
&lt;/h3&gt;

&lt;p&gt;None of this is about tides. The shape is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A nearest-neighbour matcher couples every dimension you didn't search on.&lt;/strong&gt;
You search on one axis (time, position, string distance, embedding distance)
and then assert on others (kind, label, value). Every assertion downstream of
the match inherits the search's error. The moment the search picks the wrong
counterpart, the &lt;em&gt;other&lt;/em&gt; assertions are the ones that fail — and they will
fail with an entirely credible message about a problem you do not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure mode is a false positive that reads like a serious bug.&lt;/strong&gt; A
coupled matcher does not produce noise, it produces a &lt;em&gt;specific, plausible,
wrong&lt;/em&gt; diagnosis. That is worse than a flaky test: you act on it. We deleted
two good stations from a navigation dataset and wrote the wrong explanation
into the documentation downstream of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match on the dimension you are measuring; test the others where the match
can't reach.&lt;/strong&gt; Same-kind matching for timing. Sign-at-the-expected-timestamp
for direction. If a check can only be made &lt;em&gt;after&lt;/em&gt; a match, ask whether a
mismatch would make it lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that the strict version still catches the real bug&lt;/strong&gt; — explicitly,
with an argument, in a comment. Ours: a reversed axis is half a cycle away, so
it fails the timing gate everywhere. If you can't make that argument, you have
loosened the suite rather than fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test-oracle suite needs its comparison layer audited like production
code.&lt;/strong&gt; Golden vectors get all the attention — where they come from, how
they're generated, what tolerance they're gated at. The comparison idiom
between the vectors and your output gets written once, in four lines, and
never reviewed. It is just as capable of being wrong, and when it is wrong it
discredits code that works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine and the bundle builder are both MIT, and the tide/current work is&lt;br&gt;
what keeps our own boat off the rocks in passes that only open for twenty&lt;br&gt;
minutes: &lt;a href="https://github.com/sailingnaturali/slackwater-engine" rel="noopener noreferrer"&gt;slackwater-engine&lt;/a&gt;&lt;br&gt;
(Swift) and &lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;chs-constituents&lt;/a&gt;&lt;br&gt;
(TypeScript). If a station near you disagrees with them, the validation output&lt;br&gt;
will now tell you which of the two things is actually wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;Porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-07-31-offline-tidal-current-predictions-signalk-harmonic-constituents-noaa-harcon-neaps-fallback-slack-timing-rapids%20"&gt;Offline tidal current predictions when the boat has no signal&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-07-21-utide-neaps-tide-predictor-nodal-correction-f-u-schureman-foreman-2n2-slack-water-compare-internals-not-outputs%20"&gt;Two tide libraries disagreed — compare internals, not outputs&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>swift</category>
      <category>tides</category>
      <category>marine</category>
    </item>
    <item>
      <title>A config warning on stdout made a healthy alarm lane look dead</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:04:57 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-config-warning-on-stdout-made-a-healthy-alarm-lane-look-dead-4d9m</link>
      <guid>https://dev.to/clarkbw--/a-config-warning-on-stdout-made-a-healthy-alarm-lane-look-dead-4d9m</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A CLI reported a missing environment variable by printing a&lt;br&gt;
&lt;code&gt;Config warnings&lt;/code&gt; box to &lt;strong&gt;stdout&lt;/strong&gt;. My pipeline was parsing that stdout, so&lt;br&gt;
&lt;code&gt;awk&lt;/code&gt; got box-drawing characters where data rows should be and matched&lt;br&gt;
nothing. "Nothing" read as &lt;em&gt;the alarm lane is dead&lt;/em&gt;, not &lt;em&gt;your shell is&lt;br&gt;
missing an env var&lt;/em&gt;. Forty minutes and one false outage report later:&lt;br&gt;
&lt;strong&gt;prefix the environment for any command whose output you parse, not just&lt;br&gt;
the ones that need the secret.&lt;/strong&gt; Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fconfig-warning-on-stdout-corrupts-parsed-output-stderr-vs-stdout-diagnostics-awk-returns-nothing-2-dev-null-hides-loud-failure-cli-selftest-negative-control-env-prefix-parsed-commands%2Ftwo-channels.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fconfig-warning-on-stdout-corrupts-parsed-output-stderr-vs-stdout-diagnostics-awk-returns-nothing-2-dev-null-hides-loud-failure-cli-selftest-negative-control-env-prefix-parsed-commands%2Ftwo-channels.svg" alt="One missing environment variable fails on two channels: the typed secret reference throws a loud SecretRefUnavailableError on stderr, while the string-only field prints a Config warnings box to stdout that lands inside the parsed data rows, so an awk filter returns nothing and a healthy alarm lane reads as down." width="880" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I run an agent gateway on a small boat server. It has an audit log, and the&lt;br&gt;
cheapest health check for "are the agents actually running" is a one-liner over&lt;br&gt;
the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gwcli audit &lt;span class="nt"&gt;--limit&lt;/span&gt; 12 | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2=="agent_run"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which, one morning, returned nothing at all. No error. Exit code 0. Just an&lt;br&gt;
empty result where a dozen rows had been the week before.&lt;/p&gt;

&lt;p&gt;The obvious reading — the one I made, and the one that cost the morning — is&lt;br&gt;
that there were no agent runs. Alarms flow through that gateway, so "no agent&lt;br&gt;
runs" means the soft alarm lane is down: a low-battery or depth alarm on the&lt;br&gt;
boat would raise, publish, and reach nobody. That's a real outage on the boat's&lt;br&gt;
safety path, so it got reported as one.&lt;/p&gt;

&lt;p&gt;The lane was healthy the entire time. The hook was firing about twelve seconds&lt;br&gt;
after each test alarm, which is exactly the coalesce window. Nothing was down.&lt;/p&gt;

&lt;p&gt;Here is what the command actually printed, once I stopped piping it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gwcli audit &lt;span class="nt"&gt;--limit&lt;/span&gt; 12
&lt;span class="go"&gt;┌ Config warnings ───────────────────────────────────┐
│ ! hooks.token: Missing env var GW_HOOKS_TOKEN      │
└────────────────────────────────────────────────────┘
ts                    kind        agent    status
2026-08-10T06:12:04Z  agent_run   watch    ok
2026-08-10T06:24:11Z  agent_run   watch    ok
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows were there. So was a warning box, printed &lt;strong&gt;to stdout&lt;/strong&gt;, sitting on top&lt;br&gt;
of them. &lt;code&gt;awk '$2=="agent_run"'&lt;/code&gt; doesn't crash on box-drawing characters; it just&lt;br&gt;
doesn't match them, and it never got the chance to be confused, because in the&lt;br&gt;
real invocation the box was all there was.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;The day before, I'd moved two credentials out of the gateway's config file and&lt;br&gt;
into secret references, so the config could go under version control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ~/.gwcli/config.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gateway"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"auth"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"$secret"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GW_AUTH_TOKEN"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${GW_HOOKS_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the two forms. That asymmetry looks like sloppiness and it is not — more on&lt;br&gt;
that below. The secrets themselves lived in an env file that the gateway's&lt;br&gt;
launcher script sourced before exec'ing the daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# run-gateway.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.gwcli/secrets.env"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;span class="nb"&gt;exec &lt;/span&gt;gwcli gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The daemon was fine, because the daemon goes through that script. &lt;strong&gt;A bare&lt;br&gt;
&lt;code&gt;gwcli ...&lt;/code&gt; typed in a shell, or run over SSH, does not.&lt;/strong&gt; And with the env&lt;br&gt;
missing, the two fields fail in two completely different, differently-visible&lt;br&gt;
ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Form&lt;/th&gt;
&lt;th&gt;Channel&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gateway.auth.token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;typed secret ref&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stderr&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SecretRefUnavailableError: ... unavailable in this command path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hooks.token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;${VAR}&lt;/code&gt; string&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;stdout&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;Config warnings&lt;/code&gt; box, mixed into the data you were parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The stderr one is a good failure. It's loud, it names the field, and it stops&lt;br&gt;
you. The stdout one is the dangerous half: it doesn't stop anything, it doesn't&lt;br&gt;
look like an error to a program, and it arrives &lt;em&gt;inside the data stream&lt;/em&gt;. Every&lt;br&gt;
consumer that treats stdout as structured output — &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;grep -c&lt;/code&gt;, a&lt;br&gt;
Python &lt;code&gt;subprocess.run(...).stdout&lt;/code&gt; — silently ingests a diagnostic as content.&lt;/p&gt;

&lt;p&gt;And the two compound in the worst possible way, which is the part I'd want&lt;br&gt;
another builder to take away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a completely normal scripting reflex&lt;/span&gt;
gwcli audit &lt;span class="nt"&gt;--limit&lt;/span&gt; 12 2&amp;gt;/dev/null | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2=="agent_run"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; suppresses the one failure that would have told me what was wrong,&lt;br&gt;
and leaves the one that corrupts the answer. A clean-looking pipeline, producing&lt;br&gt;
wrong data, with no visible error anywhere. That is a very hard thing to&lt;br&gt;
distrust at 6 a.m.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I tried, and why it failed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1: prefix the env only where the secret is needed.&lt;/strong&gt; My first mental&lt;br&gt;
model was "this command reads the audit log, it doesn't need the gateway token,&lt;br&gt;
so it doesn't need the env." Wrong — the config is parsed on &lt;em&gt;every&lt;/em&gt; invocation,&lt;br&gt;
so an unresolvable reference warns on every invocation, whether or not the&lt;br&gt;
subcommand touches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gwcli audit &lt;span class="nt"&gt;--limit&lt;/span&gt; 12 | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2=="agent_run"'&lt;/span&gt;     &lt;span class="c"&gt;# env not prefixed&lt;/span&gt;
&lt;span class="gp"&gt;                                                      #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;nothing — the box isn&lt;span class="s1"&gt;'t a match)
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;set -a; . ~/.gwcli/secrets.env; set +a
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;gwcli audit --limit 12 | awk '&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="s2"&gt;"agent_run"&lt;/span&gt;&lt;span class="s1"&gt;'     # env prefixed
&lt;/span&gt;&lt;span class="go"&gt;2026-08-10T06:12:04Z  agent_run   watch    ok
2026-08-10T06:24:11Z  agent_run   watch    ok
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same command, same server, same healthy lane. The only variable was my shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: a wrapper, so the prefix stops being something to remember.&lt;/strong&gt; A&lt;br&gt;
tiny script that sources the env and execs the real binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# gw — run gwcli with secrets resolved&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.gwcli/secrets.env"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
&lt;span class="nb"&gt;exec &lt;/span&gt;gwcli &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, and it survived the day. But it only helps the caller who remembers&lt;br&gt;
to type &lt;code&gt;gw&lt;/code&gt; instead of &lt;code&gt;gwcli&lt;/code&gt;, which is the same class of discipline that&lt;br&gt;
failed in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: a &lt;code&gt;--selftest&lt;/code&gt; that asserts the environment instead of trusting&lt;br&gt;
it&lt;/strong&gt; — meant for the top of any script that parses this CLI's output. This is&lt;br&gt;
the one worth writing down, because it &lt;strong&gt;passed while genuinely broken&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The CLI reports the identical condition in two different formats depending on&lt;br&gt;
the subcommand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gwcli audit
&lt;span class="go"&gt;┌ Config warnings ───────────────────────────────────┐
│ ! hooks.token: Missing env var GW_HOOKS_TOKEN      │
└────────────────────────────────────────────────────┘

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gwcli config validate
&lt;span class="go"&gt;1 warning(s): ! hooks.token: Missing env var GW_HOOKS_TOKEN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the check against &lt;code&gt;config validate&lt;/code&gt; — the subcommand whose entire job is&lt;br&gt;
to answer this question — and grepped for the string I'd been staring at all&lt;br&gt;
morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# before — passes with the token genuinely missing&lt;/span&gt;
&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gwcli config validate 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"Config warnings"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SELFTEST FAIL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SELFTEST PASS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config validate&lt;/code&gt; never prints &lt;code&gt;Config warnings&lt;/code&gt;. The grep cannot match. The&lt;br&gt;
selftest passed instantly and cleanly with a required credential absent — the&lt;br&gt;
exact state it existed to catch. Note it also carries the &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; reflex,&lt;br&gt;
so the loud channel was gone there too.&lt;/p&gt;

&lt;p&gt;What caught it was not review and not a test. It was running the &lt;strong&gt;negative&lt;br&gt;
control&lt;/strong&gt;: unsetting the variable on purpose and watching whether the check went&lt;br&gt;
red. It didn't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# after — match the substring both formats actually contain&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"missing env var"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The real fix landed a day later, and it deleted the wrapper's entire reason to&lt;br&gt;
exist.&lt;/p&gt;

&lt;p&gt;Buried in the gateway's own configuration docs: the CLI &lt;strong&gt;auto-loads a dotenv&lt;br&gt;
file from its config directory on every invocation&lt;/strong&gt; — daemon, CLI, cron,&lt;br&gt;
service unit, all of them. The secrets file already existed. It was just named&lt;br&gt;
something the tool didn't know to read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; ~/.gwcli/secrets.env ~/.gwcli/.env      &lt;span class="c"&gt;# that exact filename is the fix&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.gwcli/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gwcli audit &lt;span class="nt"&gt;--limit&lt;/span&gt; 12 | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$2=="agent_run"'&lt;/span&gt;   &lt;span class="c"&gt;# plain shell, no prefix, no wrapper&lt;/span&gt;
&lt;span class="go"&gt;2026-08-10T06:12:04Z  agent_run   watch    ok
2026-08-10T06:24:11Z  agent_run   watch    ok
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both credentials now resolve for anyone who runs the binary, from anywhere. The&lt;br&gt;
launcher script stopped sourcing anything. The wrapper survives for exactly one&lt;br&gt;
unrelated reason — &lt;code&gt;PATH&lt;/code&gt;, because the Node version manager puts the binary in a&lt;br&gt;
directory that only an interactive shell's rc file adds, so &lt;code&gt;ssh host 'gwcli …'&lt;/code&gt;&lt;br&gt;
gets &lt;code&gt;command not found&lt;/code&gt;. That's a different bug wearing the same coat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fconfig-warning-on-stdout-corrupts-parsed-output-stderr-vs-stdout-diagnostics-awk-returns-nothing-2-dev-null-hides-loud-failure-cli-selftest-negative-control-env-prefix-parsed-commands%2Fenv-resolution.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fconfig-warning-on-stdout-corrupts-parsed-output-stderr-vs-stdout-diagnostics-awk-returns-nothing-2-dev-null-hides-loud-failure-cli-selftest-negative-control-env-prefix-parsed-commands%2Fenv-resolution.svg" alt="Before the fix only the launcher script sourced the secrets file, so a bare CLI invocation loaded nothing and printed config warnings to stdout; after renaming the file to the dotenv path the CLI auto-loads on every invocation, every path resolves both credentials and the wrapper's reason to exist disappears." width="880" height="350"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The selftest stayed, because renaming a file doesn't stop it going missing or a&lt;br&gt;
key rotating out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gwcli config validate 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"missing env var"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it matters, and the gotchas around it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prefix the env for any command whose output you parse, not just the ones that&lt;br&gt;
need the secret.&lt;/strong&gt; That's the rule I'd hand to anyone else running a CLI inside&lt;br&gt;
a pipeline. It's counter-intuitive precisely because the failing subcommand had&lt;br&gt;
nothing to do with the missing credential.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnostics belong on stderr — and you are not in control of that.&lt;/strong&gt; This is&lt;br&gt;
the oldest rule in the Unix toolbox and every CLI breaks it eventually, usually&lt;br&gt;
via a pretty-printing layer that wraps &lt;code&gt;console.log&lt;/code&gt;. Assume a tool you depend&lt;br&gt;
on will one day put a banner in your data. The defences are cheap: pass whatever&lt;br&gt;
&lt;code&gt;--json&lt;/code&gt; / &lt;code&gt;--quiet&lt;/code&gt; / &lt;code&gt;--no-color&lt;/code&gt; flag exists and parse a structured format&lt;br&gt;
rather than a table; or fail loudly when the shape is wrong, instead of treating&lt;br&gt;
an empty match as a fact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An empty result is not evidence.&lt;/strong&gt; &lt;code&gt;awk&lt;/code&gt; matching zero rows means "no rows&lt;br&gt;
matched", which covers &lt;em&gt;there is no data&lt;/em&gt;, &lt;em&gt;the data moved&lt;/em&gt;, and &lt;em&gt;what I handed&lt;br&gt;
you wasn't data at all&lt;/em&gt;. My health check couldn't tell those apart, and the one&lt;br&gt;
it silently chose was the alarming one. A parse that can return empty needs a&lt;br&gt;
sanity assertion — did the header row arrive? — before its emptiness is allowed&lt;br&gt;
to mean anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; is a load-bearing decision, not punctuation.&lt;/strong&gt; In this incident&lt;br&gt;
it was the difference between a five-second diagnosis and a false outage report.&lt;br&gt;
If you're silencing stderr because a command is chatty, silence it &lt;em&gt;after&lt;/em&gt;&lt;br&gt;
you've checked the exit code, or route it to a file you actually read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You haven't tested a check until you've watched it fail.&lt;/strong&gt; Break the thing on&lt;br&gt;
purpose, confirm red, put it back, confirm green. A selftest that passes when&lt;br&gt;
broken is worse than no selftest — it converts an unknown into a false known,&lt;br&gt;
and it is exactly the kind of green tick you'd cite while reporting an outage&lt;br&gt;
that isn't happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One deliberate non-fix.&lt;/strong&gt; The two-form asymmetry in the config — one field a&lt;br&gt;
typed secret-reference object, the other a &lt;code&gt;${VAR}&lt;/code&gt; string — is schema-forced,&lt;br&gt;
not style drift: the second field's schema is string-only and cannot take the&lt;br&gt;
object form, which is &lt;em&gt;why&lt;/em&gt; it's the one that emits a warning rather than&lt;br&gt;
throwing. That's now a comment in the config, because it reads as an&lt;br&gt;
inconsistency and the obvious "cleanup" would put the whole failure straight&lt;br&gt;
back.&lt;/p&gt;

&lt;p&gt;This is part of the agent stack I'm building for an all-electric charter&lt;br&gt;
catamaran that doesn't exist yet — the same lane the false report was about is&lt;br&gt;
the one that carries &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt;&lt;br&gt;
alarms to a phone. It stayed up. My confidence in my own health check did not.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt; — the same alarm lane failing for real, and why a quiet system produces nothing to notice; and &lt;a href="https://dev.to%20post_url%202026-07-29-openclaw-raspberry-pi-signalk-boat-agent-gateway-mode-local-si-units-tools-profile-prompt-caching%20"&gt;Running OpenClaw on a Raspberry Pi alongside SignalK&lt;/a&gt; — the gateway this CLI drives.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>shell</category>
      <category>bash</category>
      <category>stdout</category>
    </item>
    <item>
      <title>MapLibre's SDF icon edge is at 0.75, not 0.5</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:57:05 +0000</pubDate>
      <link>https://dev.to/clarkbw--/maplibres-sdf-icon-edge-is-at-075-not-05-30d7</link>
      <guid>https://dev.to/clarkbw--/maplibres-sdf-icon-edge-is-at-075-not-05-30d7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — If you hand-author the alpha channel of an SDF icon for&lt;br&gt;
&lt;code&gt;map.addImage(id, img, { sdf: true })&lt;/code&gt;, the edge does &lt;strong&gt;not&lt;/strong&gt; go at alpha 128.&lt;br&gt;
MapLibre's shader thresholds the fill at &lt;code&gt;inner_edge = (256.0 - 64.0) / 256.0&lt;/code&gt;&lt;br&gt;
= &lt;strong&gt;0.75&lt;/strong&gt;, i.e. alpha &lt;strong&gt;191&lt;/strong&gt;, over an 8-texture-pixel field. Encode the edge&lt;br&gt;
at 128 and the shader draws a contour 4 px &lt;em&gt;inside&lt;/em&gt; your shape — anything&lt;br&gt;
thinner than 8 px vanishes completely, silently, with correct geometry and no&lt;br&gt;
error of any kind. Use &lt;code&gt;alpha = 255 * (1 - cutoff - d / SDF_PX)&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;cutoff = 0.25&lt;/code&gt;, &lt;code&gt;SDF_PX = 8&lt;/code&gt;. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We draw station pins on a MapLibre chart in&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;Slackwater&lt;/a&gt;, an offline&lt;br&gt;
tide-and-current app for the Salish Sea. The pins need to change colour with&lt;br&gt;
live state — flooding, ebbing, slack — and they need a halo so they stay legible&lt;br&gt;
over bathymetry contours. In MapLibre, &lt;code&gt;icon-color&lt;/code&gt; and &lt;code&gt;icon-halo-color&lt;/code&gt; only&lt;br&gt;
do anything on &lt;strong&gt;SDF&lt;/strong&gt; icons, so the pin has to be registered as a signed&lt;br&gt;
distance field.&lt;/p&gt;

&lt;p&gt;The glyphs are simple stroke geometry — a sine wave for a current station, a&lt;br&gt;
dome over a datum line for a tide station — so rather than rasterise a path and&lt;br&gt;
hope, we computed the field analytically: exact distance from each pixel to the&lt;br&gt;
stroke centreline, minus half the stroke width. A canvas-rasterised path gives&lt;br&gt;
you a 1 px coverage ramp, which is enough to tint an icon but nowhere near&lt;br&gt;
enough to hang a halo on.&lt;/p&gt;

&lt;p&gt;The geometry was right. The icons rendered as nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Falpha-ramp.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Falpha-ramp.svg" alt="MapLibre draws the icon contour where the encoded field reads 0.75, so an edge encoded at alpha 128 puts the drawn contour 4 texture pixels inside the true edge and the glyph's peak alpha of 176 never crosses the threshold."&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: a correct icon that draws zero pixels
&lt;/h2&gt;

&lt;p&gt;The registration is unremarkable, and it succeeds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;pinGlyphImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pixelRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PIN_PIXEL_RATIO&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So is the layer:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;station-pins&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;symbol&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&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;icon-image&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;match&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;kind&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;current&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;pin-current&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;pin-tide&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;icon-size&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;icon-allow-overlap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;icon-ignore-placement&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;paint&lt;/span&gt;&lt;span class="p"&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;icon-color&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;match&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&gt;state&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;flood&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;#4a9fd8&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;ebb&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;#e8a33d&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;#7d9cb8&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;icon-halo-color&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;#0b1a2b&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;icon-halo-width&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.5&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 the field generator looked right too. &lt;code&gt;signedDistance&lt;/code&gt; returns the exact&lt;br&gt;
distance to the union of strokes, negative inside; the encoding was the obvious&lt;br&gt;
one:&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;SPREAD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signedDistance&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// maplibre's SDF convention: 128 is the edge, higher is inside.&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="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="mi"&gt;255&lt;/span&gt; &lt;span class="o"&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;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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.5&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&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="nx"&gt;SPREAD&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is the whole bug. It is stated with total confidence and it is&lt;br&gt;
wrong.&lt;/p&gt;

&lt;p&gt;There is no error. &lt;code&gt;map.addImage&lt;/code&gt; resolves, &lt;code&gt;map.hasImage(id)&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;,&lt;br&gt;
the source has features, the layer is in the style, nothing appears in the&lt;br&gt;
console, and WebGL is perfectly happy. You get a map with correct data on it and&lt;br&gt;
no pins.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis: read the shader
&lt;/h2&gt;

&lt;p&gt;MapLibre's icons and its text glyphs go through the same fragment shader. The&lt;br&gt;
whole answer is six lines of&lt;br&gt;
&lt;a href="https://github.com/maplibre/maplibre-gl-js/blob/v5.24.0/src/shaders/glsl/symbol_sdf.fragment.glsl" rel="noopener noreferrer"&gt;&lt;code&gt;src/shaders/glsl/symbol_sdf.fragment.glsl&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
in maplibre-gl 5.24.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define SDF_PX 8.0
&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kt"&gt;lowp&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;inner_edge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;64&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="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// line 34  -&amp;gt; 0.75&lt;/span&gt;
&lt;span class="kt"&gt;lowp&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;texture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u_texture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tex&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kt"&gt;highp&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;smoothstep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inner_edge&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;gamma_scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inner_edge&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gamma_scaled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dist&lt;/code&gt; is your alpha channel, normalised to 0–1. The shader paints the contour&lt;br&gt;
where &lt;code&gt;dist&lt;/code&gt; equals &lt;strong&gt;0.75&lt;/strong&gt; — alpha &lt;strong&gt;191&lt;/strong&gt; — not 0.5. (In the sibling&lt;br&gt;
&lt;code&gt;symbol_text_and_icon&lt;/code&gt; shader the same constant is spelled &lt;code&gt;buff&lt;/code&gt;, which is the&lt;br&gt;
older Mapbox name you will see quoted around the web; the value is identical.)&lt;/p&gt;

&lt;p&gt;Now the arithmetic. Take any linear encoding of the form&lt;br&gt;
&lt;code&gt;alpha = 255 * (A - d / R)&lt;/code&gt;, where &lt;code&gt;A&lt;/code&gt; is the value you put at &lt;code&gt;d = 0&lt;/code&gt; and &lt;code&gt;R&lt;/code&gt;&lt;br&gt;
is the distance over which the field ramps. The shader draws the contour where&lt;br&gt;
&lt;code&gt;A - d / R = 0.75&lt;/code&gt;, that is:&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;R&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line explains everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;encoding&lt;/th&gt;
&lt;th&gt;A&lt;/th&gt;
&lt;th&gt;R&lt;/th&gt;
&lt;th&gt;contour the shader draws&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ours (broken)&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;d = -4 px&lt;/code&gt; — 4 px &lt;strong&gt;inside&lt;/strong&gt; the true edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;edge at 0.5, correct ramp&lt;/td&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;d = -2 px&lt;/code&gt; — 2 px inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;correct&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;d = 0&lt;/code&gt; — the true edge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;A&lt;/code&gt; has to be 0.75 or the shader renders an eroded version of your shape. &lt;code&gt;R&lt;/code&gt;&lt;br&gt;
does not affect where the edge lands at all — it only sets the ramp rate, which&lt;br&gt;
is what halo widths and antialiasing are scaled against.&lt;/p&gt;

&lt;p&gt;Our strokes are &lt;code&gt;0.14 * 44 = 6.16&lt;/code&gt; texture pixels wide, so the deepest interior&lt;br&gt;
point sits at &lt;code&gt;d = -3.07&lt;/code&gt;. A 4 px erosion of a 6.16 px stroke leaves nothing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Fstroke-erosion.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Fstroke-erosion.svg" alt="A cross-section of the 6.16 pixel wide glyph stroke showing that the correct encoding draws the whole stroke while the broken encoding insets 4 pixels from each side, and the two insets cross so no pixels remain."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run the numbers over the actual field and the result is exact: peak alpha 176,&lt;br&gt;
threshold 191.25, &lt;strong&gt;0 of 1936 pixels&lt;/strong&gt; above it. The antialiasing band does not&lt;br&gt;
save you either — &lt;code&gt;EDGE_GAMMA = 0.105 / u_device_pixel_ratio&lt;/code&gt; puts the&lt;br&gt;
smoothstep's lower edge around alpha 178 at &lt;code&gt;devicePixelRatio&lt;/code&gt; 2, still above the&lt;br&gt;
field's maximum.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why 0.75 is not an arbitrary number
&lt;/h3&gt;

&lt;p&gt;The intuition that says "0.5" is really the intuition that the field should be&lt;br&gt;
&lt;em&gt;symmetric&lt;/em&gt; around the edge. MapLibre's is deliberately asymmetric, and the&lt;br&gt;
reason is halos.&lt;/p&gt;

&lt;p&gt;The encoding comes from&lt;br&gt;
&lt;a href="https://github.com/mapbox/tiny-sdf/blob/v2.2.0/index.js#L119-L123" rel="noopener noreferrer"&gt;&lt;code&gt;@mapbox/tiny-sdf&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
whose defaults are &lt;code&gt;radius = 8&lt;/code&gt;, &lt;code&gt;cutoff = 0.25&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;radius&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;len&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gridOuter&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&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;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gridInner&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;base&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;d&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;which is &lt;code&gt;alpha = 255 * (1 - cutoff - d / radius)&lt;/code&gt;. With &lt;code&gt;cutoff = 0.25&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;radius = 8&lt;/code&gt; the field saturates 2 px inside the shape and runs out 6 px&lt;br&gt;
outside. The edge therefore sits &lt;code&gt;6 / 8 = 0.75&lt;/code&gt; of the way up the range — and&lt;br&gt;
that is the same 0.75, arrived at from the other direction. MapLibre uses&lt;br&gt;
exactly these constants for its own glyph atlas, in&lt;br&gt;
&lt;a href="https://github.com/maplibre/maplibre-gl-js/blob/v5.24.0/src/render/glyph_manager.ts#L208-L217" rel="noopener noreferrer"&gt;&lt;code&gt;glyph_manager.ts&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
(&lt;code&gt;radius: 8 * textureScale, cutoff: 0.25&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The 6 px is not a coincidence either. It reappears in the halo branch of the&lt;br&gt;
same shader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight glsl"&gt;&lt;code&gt;&lt;span class="kt"&gt;highp&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;halo_edge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;halo_width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fontScale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SDF_PX&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// line 53&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six pixels of outside room is precisely the widest halo the shader can express.&lt;br&gt;
That is what the asymmetry buys.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Ffield-budget.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fmaplibre-custom-sdf-icon-invisible-not-showing-addimage-sdf-true-alpha-edge-0.75-not-0.5-inner-edge-256-64-256-tiny-sdf-cutoff-0.25-sdf-px-8%2Ffield-budget.svg" alt="The signed distance field budgets 2 texture pixels inside the shape and 6 outside, so the shape edge lands 6 eighths of the way up the range, which is why the MapLibre fill threshold is 0.75 and why the maximum icon halo width is 6 pixels."&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  The test that asserted the bug
&lt;/h3&gt;

&lt;p&gt;The glyph generator shipped with a unit test written specifically to pin down&lt;br&gt;
the alpha encoding. It passed:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puts the shape edge at alpha 128 — inside is brighter, far outside is dark&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pinGlyphImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tide&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;mid&lt;/span&gt; &lt;span class="o"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alphaAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&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;column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alphaAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&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;Corner alpha is 0, which is less than 128. Peak column alpha is 176, which is&lt;br&gt;
greater than 128. Green, on a glyph that renders zero pixels.&lt;/p&gt;

&lt;p&gt;The test asserted the same wrong constant the implementation did, so it could&lt;br&gt;
only ever confirm the code agreed with itself. &lt;code&gt;toBeGreaterThan(128)&lt;/code&gt; is&lt;br&gt;
satisfied by 176 — the value that proves the bug — and by 255. The assertion had&lt;br&gt;
no power exactly where it mattered.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Just tighten the spread until something appears"
&lt;/h3&gt;

&lt;p&gt;The tempting empirical fix, once you know the icons are invisible but not&lt;br&gt;
&lt;em&gt;why&lt;/em&gt;, is to make the field steeper and see what shows up. It does eventually&lt;br&gt;
produce marks, which is the trap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ramp span &lt;code&gt;R&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;contour drawn&lt;/th&gt;
&lt;th&gt;what you see&lt;/th&gt;
&lt;th&gt;outside room left&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;&lt;code&gt;d = -4.00 px&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;td&gt;8 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;&lt;code&gt;d = -3.00 px&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a 0.16 px thread&lt;/td&gt;
&lt;td&gt;6 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;d = -2.50 px&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a 1.16 px hairline&lt;/td&gt;
&lt;td&gt;5 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;code&gt;d = -2.00 px&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.16 px of a 6.16 px stroke&lt;/td&gt;
&lt;td&gt;4 px&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At &lt;code&gt;R = 8&lt;/code&gt; with the edge still at 0.5 you get something that looks plausible on&lt;br&gt;
screen — a thin version of the glyph — and you ship a shape that is 2 px&lt;br&gt;
narrower everywhere than the one you drew, with a field that runs out 4 px&lt;br&gt;
outside instead of 6, so &lt;code&gt;icon-halo-width&lt;/code&gt; values above 4 quietly clip. Tuning&lt;br&gt;
by eye lands you here.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Add a constant so the edge reads 192"
&lt;/h3&gt;

&lt;p&gt;Also close, also wrong:&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;// edge lands at 192 — but the ramp rate is still 1/16 per px&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="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="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contour now falls at &lt;code&gt;d = 0&lt;/code&gt;, so the shape is the right size. But the ramp&lt;br&gt;
is half as steep as &lt;code&gt;SDF_PX = 8&lt;/code&gt; assumes, so every halo width comes out at the&lt;br&gt;
wrong scale, and the far field bottoms out at a floor of 64 instead of 0 — the&lt;br&gt;
alpha channel never reaching zero on an image you also might want to reuse&lt;br&gt;
non-SDF. Two knobs, and this only turns one of them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Encode the edge at the threshold the shader actually uses, and ramp at the rate&lt;br&gt;
it actually assumes:&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;/**
 * maplibre's SDF constants, not ours to choose: the shader thresholds the icon
 * fill at `inner_edge = (256 - 64) / 256 = 0.75` over a field authored with
 * `SDF_PX = 8` texture pixels. Matches `@mapbox/tiny-sdf`'s `cutoff = 0.25`.
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SDF_PX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;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;SDF_CUTOFF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&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;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signedDistance&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;strokes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// 255 * (1 - cutoff - d/SDF_PX): edge (d=0) lands at ~191, the interior&lt;/span&gt;
&lt;span class="c1"&gt;// saturates to 255, and the field reaches 0 at 6px outside the stroke.&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="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="mi"&gt;255&lt;/span&gt; &lt;span class="o"&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;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;SDF_CUTOFF&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;SDF_PX&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, and it is just tiny-sdf's own formula. The edge lands at 191, the&lt;br&gt;
interior saturates at 255, the field runs out 6 px outside.&lt;/p&gt;

&lt;p&gt;The test gets the real invariant — saturation, not "brighter than the value we&lt;br&gt;
assumed":&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puts the shape edge at maplibre's 0.75 threshold, and saturates inside&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pinGlyphImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tide&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;mid&lt;/span&gt; &lt;span class="o"&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alphaAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&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;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&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="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;alphaAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;column&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;a&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;205&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the edge band&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toBe(255)&lt;/code&gt; is the assertion that has teeth. An interior that never reaches 255&lt;br&gt;
is an interior that may never cross 0.75, and that is the failure this whole&lt;br&gt;
post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters, and the traps next door
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The failure mode is silence.&lt;/strong&gt; There is no error string to search for, which&lt;br&gt;
is why this costs hours rather than minutes — you go looking for a bug in your&lt;br&gt;
geometry, your projection, your GeoJSON, your collision settings. The symptom is&lt;br&gt;
an absence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is worse than silent, actually.&lt;/strong&gt; Our pins carry&lt;br&gt;
&lt;code&gt;"icon-halo-color": "#0b1a2b"&lt;/code&gt; — the same value as the map's background. The&lt;br&gt;
halo branch thresholds at &lt;code&gt;halo_edge = (6.0 - halo_width / fontScale) / SDF_PX&lt;/code&gt;,&lt;br&gt;
which for &lt;em&gt;any&lt;/em&gt; non-zero halo width is &lt;strong&gt;below&lt;/strong&gt; the 0.75 fill threshold. So a&lt;br&gt;
field that is too dark to draw a fill can still be bright enough to draw a halo.&lt;br&gt;
With a halo colour that matches the water, the glyph paints itself in the&lt;br&gt;
background tone. Worth knowing before you conclude "nothing rendered."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The docs do not tell you.&lt;/strong&gt; MapLibre's&lt;br&gt;
&lt;a href="https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/StyleImageMetadata/" rel="noopener noreferrer"&gt;&lt;code&gt;StyleImageMetadata&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
documents the &lt;code&gt;sdf&lt;/code&gt; flag in full as: &lt;em&gt;"Whether the image should be interpreted&lt;br&gt;
as an SDF image."&lt;/em&gt; That is the entire specification of the alpha format.&lt;br&gt;
&lt;a href="https://github.com/mapbox/mapbox-gl-style-spec/issues/97" rel="noopener noreferrer"&gt;mapbox/mapbox-gl-style-spec#97&lt;/a&gt;&lt;br&gt;
— "Style reference: document sdf icons" — was opened in 2014 and never resolved;&lt;br&gt;
&lt;a href="https://github.com/maplibre/maplibre-native/issues/2551" rel="noopener noreferrer"&gt;maplibre/maplibre-native#2551&lt;/a&gt;&lt;br&gt;
asks for the same thing and is still open. The one human-readable statement of&lt;br&gt;
the number I could find anywhere is a paragraph on a Mapbox&lt;br&gt;
&lt;a href="https://docs.mapbox.com/help/troubleshooting/using-recolorable-images-in-mapbox-maps/" rel="noopener noreferrer"&gt;troubleshooting page about recolorable images&lt;/a&gt;:&lt;br&gt;
&lt;em&gt;"values between 192 and 255 represent 'inside' a glyph and values from 0 to 191&lt;br&gt;
represent 'outside'."&lt;/em&gt; Correct, and filed under glyphs, and never connected to&lt;br&gt;
&lt;code&gt;addImage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scale-invariant part is the part you must get right.&lt;/strong&gt; &lt;code&gt;A = 0.75&lt;/code&gt; is fixed&lt;br&gt;
— it is a threshold on a normalised alpha, so it holds whatever resolution you&lt;br&gt;
author at. &lt;code&gt;R&lt;/code&gt; scales with your image: we author at &lt;code&gt;pixelRatio: 2&lt;/code&gt; with&lt;br&gt;
&lt;code&gt;R = 8&lt;/code&gt;, while MapLibre's own glyph atlas uses &lt;code&gt;radius: 8 * textureScale&lt;/code&gt;.&lt;br&gt;
Getting &lt;code&gt;A&lt;/code&gt; wrong makes your icon vanish; getting &lt;code&gt;R&lt;/code&gt; wrong makes your halo&lt;br&gt;
widths come out at the wrong scale. Check the halo by eye, but check the edge by&lt;br&gt;
arithmetic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the general one.&lt;/strong&gt; A test that asserts a constant the implementation also&lt;br&gt;
asserts is not a test, it is a restatement. The useful assertion was not "is the&lt;br&gt;
edge where we think" — it was "does the interior saturate," a property with a&lt;br&gt;
single correct answer that does not depend on the belief being tested.&lt;/p&gt;




&lt;p&gt;Slackwater is the offline tide and current app for the Salish Sea we run&lt;br&gt;
alongside the boat's software stack — the sort of thing you want working at&lt;br&gt;
anchor with no signal. Code:&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;slackwater-web&lt;/a&gt;,&lt;br&gt;
&lt;code&gt;src/pinGlyphs.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; — the other half of getting numeric code to tell you when it is wrong, and &lt;a href="https://dev.to%20post_url%202026-07-31-offline-tidal-current-predictions-signalk-harmonic-constituents-noaa-harcon-neaps-fallback-slack-timing-rapids%20"&gt;offline tidal current predictions from harmonic constituents&lt;/a&gt; — what the pins in this post are actually showing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>maplibre</category>
      <category>webgl</category>
      <category>maps</category>
      <category>typescript</category>
    </item>
    <item>
      <title>claude setup-token fixed my launchd auth and dropped my connectors</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 01 Sep 2026 23:54:55 +0000</pubDate>
      <link>https://dev.to/clarkbw--/claude-setup-token-fixed-my-launchd-auth-and-dropped-my-connectors-3288</link>
      <guid>https://dev.to/clarkbw--/claude-setup-token-fixed-my-launchd-auth-and-dropped-my-connectors-3288</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;If you added &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; to a scheduled &lt;code&gt;claude -p&lt;/code&gt; job, your claude.ai connectors — Gmail, Google Calendar, Google Drive — are gone, and nothing told you. A &lt;code&gt;setup-token&lt;/code&gt; "can only make model requests", and it sits &lt;strong&gt;above&lt;/strong&gt; your &lt;code&gt;/login&lt;/code&gt; session in Claude Code's auth precedence, so setting it silently demotes the session the connectors ride on. They don't error; they never enter the tool list. Keep the keychain session primary and make the token an explicitly-degraded fallback. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fclaude-setup-token-drops-claude-ai-connectors-gmail-calendar-headless-claude-p-launchd-keychain-oauth-token-precedence%2Fconnector-auth-path.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fclaude-setup-token-drops-claude-ai-connectors-gmail-calendar-headless-claude-p-launchd-keychain-oauth-token-precedence%2Fconnector-auth-path.svg" alt="With a /login keychain session Claude Code fetches the claude.ai account session and the Gmail, Google Calendar and Google Drive connectors appear in the tool list; with CLAUDE_CODE_OAUTH_TOKEN set the account session is never fetched and those three connectors never appear at all, while model requests succeed in both lanes." width="880" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I run a daily scheduled agent job on a Mac: a launchd agent fires a headless &lt;code&gt;claude -p&lt;/code&gt; at 06:05, the agent reads a planning repo, sweeps my email and calendar for anything due, and writes a standup file. It has been reliable for months. Then it was blind for four days and every one of those runs told me it had succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: launchd, a keychain, and a fix that worked
&lt;/h2&gt;

&lt;p&gt;One morning the job just didn't produce anything. The log had the useful part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== daily-standup 06:05 ===
OAuth session expired and could not be refreshed
=== daily-standup FAILED (exit 1): no artifact written ===
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(On current builds this surfaces as &lt;code&gt;Login expired · Please run /login&lt;/code&gt;; the CLI &lt;a href="https://code.claude.com/docs/en/authentication#renew-an-expiring-login" rel="noopener noreferrer"&gt;documents both the warning and the expired state&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;This is the classic unattended-macOS problem. Claude Code stores subscription credentials in the encrypted login Keychain, and a job launched by launchd on a machine that woke from sleep is not the same thing as you sitting at a terminal. There is a &lt;a href="https://github.com/anthropics/claude-code/issues/77213" rel="noopener noreferrer"&gt;long-running open bug&lt;/a&gt; where a process tree rooted at launchd reports "not logged in" against credentials that &lt;code&gt;security find-generic-password&lt;/code&gt; can read perfectly well from the same context. Whatever the precise cause on any given day, the symptom is the same: an interactive login is not a durable credential for a background job.&lt;/p&gt;

&lt;p&gt;Anthropic documents the answer, and it is a good one:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For CI pipelines, scripts, or other environments where interactive browser login isn't available, generate a one-year OAuth token with &lt;code&gt;claude setup-token&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/authentication#generate-a-long-lived-token" rel="noopener noreferrer"&gt;Claude Code docs, Authentication&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I ran it, put the token in the env file the job already sources, and moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scheduled wrapper — the version that "fixed" it&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;                          &lt;span class="c"&gt;# the env file is bare KEY=val with no `export`,&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.agent/.env"&lt;/span&gt;      &lt;span class="c"&gt;# so allexport is what makes the child inherit it&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; +a

claude &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; bypassPermissions &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That env file holds about twenty keys — analytics credentials, a couple of API keys, and now the Claude token. One line, one variable, done. The job ran green the next morning and every morning after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: the run wasn't failing, it was half-blind
&lt;/h2&gt;

&lt;p&gt;Four days later I noticed the standups had stopped mentioning email. Not "failed to read email" — just nothing, as if the week had been quiet. The agent's own summary of what it swept listed the repo and nothing else, and I'd skimmed past it four times.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fclaude-setup-token-drops-claude-ai-connectors-gmail-calendar-headless-claude-p-launchd-keychain-oauth-token-precedence%2Ffour-green-runs.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fclaude-setup-token-drops-claude-ai-connectors-gmail-calendar-headless-claude-p-launchd-keychain-oauth-token-precedence%2Ffour-green-runs.svg" alt="Six consecutive daily agent runs: the run that failed outright was noticed the same morning, while the four runs after the OAuth token was added all exited 0 and wrote their artifact with the email and calendar connectors missing, and went unnoticed for four days." width="880" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The check took thirty seconds once I thought to run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;claude mcp list
&lt;span class="go"&gt;claude.ai Gmail              ✓ Connected
claude.ai Google Calendar    ✓ Connected
claude.ai Google Drive       ✓ Connected
signalk                      ✓ Connected

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-oat01-… claude mcp list
&lt;span class="go"&gt;signalk                      ✓ Connected
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same machine, same second, same config. The only difference is one environment variable, and three servers vanish. Not "failed", not "needs authentication" — &lt;strong&gt;absent&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why: connectors ride on the account session, and the token outranks it
&lt;/h3&gt;

&lt;p&gt;Two things in the docs explain it completely, and I had read neither, because I was solving a launchd problem and this is filed under authentication and MCP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; A &lt;code&gt;setup-token&lt;/code&gt; is deliberately narrow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This token authenticates with your Claude subscription and requires a Pro, Max, Team, or Enterprise plan. It &lt;strong&gt;can only make model requests&lt;/strong&gt;, so it can't establish Remote Control sessions or fetch claude.ai connectors. MCP servers you configure locally still work.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/authentication#generate-a-long-lived-token" rel="noopener noreferrer"&gt;Authentication § Generate a long-lived token&lt;/a&gt; (emphasis mine)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; The MCP page spells out the exclusion list, and the last bullet is this exact situation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Connectors from claude.ai are fetched only when your active authentication method is a claude.ai subscription login. They aren't loaded, even if you previously ran &lt;code&gt;/login&lt;/code&gt;, when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt;, or &lt;code&gt;apiKeyHelper&lt;/code&gt; is active&lt;/li&gt;
&lt;li&gt;A third-party provider such as Amazon Bedrock or Google Cloud's Agent Platform is active&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ANTHROPIC_PROFILE&lt;/code&gt;, the federation variables, or an active Anthropic profile supplies the credential&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; holds a token from &lt;code&gt;claude setup-token&lt;/code&gt;, which can only make model requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;— &lt;a href="https://code.claude.com/docs/en/mcp#use-mcp-servers-from-claude-ai" rel="noopener noreferrer"&gt;Connect Claude Code to tools via MCP § Use MCP servers from claude.ai&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The mechanism is the &lt;a href="https://code.claude.com/docs/en/authentication#authentication-precedence" rel="noopener noreferrer"&gt;authentication precedence list&lt;/a&gt;. &lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; is rank 5. Your &lt;code&gt;/login&lt;/code&gt; subscription credential is rank 7 — dead last:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;claude.ai connectors?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Cloud provider (Bedrock / Vertex / Foundry)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apiKeyHelper&lt;/code&gt; output&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/code&gt; (&lt;code&gt;setup-token&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Anthropic profile / federation credentials&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Subscription OAuth from &lt;code&gt;/login&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read as a table, the shape of the trap is obvious: &lt;strong&gt;connectors are available under exactly one of seven credential sources, and it's the one anything else beats.&lt;/strong&gt; Setting &lt;em&gt;any&lt;/em&gt; of the six above your login costs you the connectors. &lt;code&gt;setup-token&lt;/code&gt; is just the one you're most likely to add on purpose, at 6am, to fix something else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that actually cost four days
&lt;/h3&gt;

&lt;p&gt;Getting it wrong is forgivable. Not being told is the expensive bit, and Claude Code is otherwise good about this. For a server you configured locally that needs authentication, headless runs get an explicit signal — &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;as of v2.1.196&lt;/a&gt;, "Claude Code tells Claude that the server's tools are unavailable until you authorize it," so the model can name the server instead of answering as if it were never configured.&lt;/p&gt;

&lt;p&gt;Connectors dropped by auth method get none of that, and structurally can't: they were never fetched, so there is no server object to mark unavailable. The list the model receives is simply shorter. The model has no way to know, and answers confidently from what's left.&lt;/p&gt;

&lt;p&gt;So the run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;exits &lt;code&gt;0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;writes its artifact&lt;/li&gt;
&lt;li&gt;reports success&lt;/li&gt;
&lt;li&gt;and is missing half its inputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is a much worse failure than the crash I was fixing. The crash cost me one standup and announced itself before breakfast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried, and why each one failed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1 — put the token in the shared env file.&lt;/strong&gt; This is the whole bug, and its real lesson isn't about Claude at all: &lt;code&gt;source&lt;/code&gt;-ing a general-purpose env file into an agent runner is an &lt;em&gt;authentication change&lt;/em&gt;, not a configuration convenience. The blast radius of &lt;code&gt;set -a; source .env&lt;/code&gt; is every variable in that file, including the ones another tool put there. I added a key to make a scheduler happy and silently changed which account session my agent ran under.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2 — trust the exit code.&lt;/strong&gt; The wrapper's health check was "did &lt;code&gt;claude&lt;/code&gt; exit non-zero", and every degraded morning it said yes. Exit &lt;code&gt;0&lt;/code&gt; from an agent run is a claim about the process, not about the work. Worse, I also had the agent report its own coverage in the output — and it did, accurately, listing only the sources it could see. A self-report is only as good as the reader; four days of "swept: repo" scrolled by unread.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3 — check for today's artifact by exact filename.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ARTIFACT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"reports/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.md"&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARTIFACT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ARTIFACT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks airtight and isn't. On the first morning of the fix, the agent inferred the wrong date and filed a perfectly good report under a filename three days in the future. The check saw no &lt;code&gt;reports/2026-08-01.md&lt;/code&gt;, declared total failure, and fired a redundant retry over a successful run. Two fixes fell out: the heartbeat should ask "did &lt;em&gt;a&lt;/em&gt; new artifact appear", not "did &lt;em&gt;this&lt;/em&gt; filename appear", and the prompt should never let the model infer the date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PROMPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"TODAY IS &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;. Use that date for the filename and every date computation — do not infer it.

&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROMPT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Attempt 4 — &lt;code&gt;find -newermt&lt;/code&gt; for "any new artifact".&lt;/strong&gt; The obvious one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wrote_artifact&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find reports &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.md'&lt;/span&gt; &lt;span class="nt"&gt;-newermt&lt;/span&gt; &lt;span class="s2"&gt;"@&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; &lt;span class="nt"&gt;-quit&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned false inside the script while the same predicate, same &lt;code&gt;STAMP&lt;/code&gt;, same directory, returned true when I ran it by hand. It fired two more redundant degraded runs before I gave up guessing and made the check say what it saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"heartbeat: STAMP=&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt; now=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; newest=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; reports/&lt;span class="k"&gt;*&lt;/span&gt;.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; mtime=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; reports/&lt;span class="k"&gt;*&lt;/span&gt;.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mtime was comfortably newer than &lt;code&gt;STAMP&lt;/code&gt;. &lt;code&gt;find&lt;/code&gt; disagreed anyway. I never did root-cause the &lt;code&gt;find(1)&lt;/code&gt; quirk, and I'm at peace with that: two stdlib commands answer the question outright, so the correct move was to delete the clever predicate rather than debug it. A watchdog that produces false negatives is worse than no watchdog — it trains you to ignore it.&lt;/p&gt;

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

&lt;p&gt;Two changes. &lt;strong&gt;Keychain session primary, token as an explicitly-degraded fallback&lt;/strong&gt;, and &lt;strong&gt;success means "the artifact exists and is newer than this run started"&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Ground truth: did THIS run produce an artifact? Not the exit code, not the&lt;/span&gt;
&lt;span class="c"&gt;# agent's self-report. `stat -f %m` is BSD/macOS; GNU coreutils is `stat -c %Y`.&lt;/span&gt;
wrote_artifact&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;newest
  &lt;span class="nv"&gt;newest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; reports/&lt;span class="k"&gt;*&lt;/span&gt;.md 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$newest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$newest&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Primary run: the claude.ai /login session, with the token pulled OUT of the&lt;/span&gt;
&lt;span class="c"&gt;# environment so it can't outrank it. This is the only credential that carries&lt;/span&gt;
&lt;span class="c"&gt;# the Gmail / Calendar / Drive connectors.&lt;/span&gt;
&lt;span class="nv"&gt;OAT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;CLAUDE_CODE_OAUTH_TOKEN
claude &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; bypassPermissions &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;

&lt;span class="c"&gt;# Fallback: an expired session must not cost a whole day's run — retry with the&lt;/span&gt;
&lt;span class="c"&gt;# token, and say out loud that this one is degraded.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OAT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; wrote_artifact&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"primary (keychain session) run wrote nothing, exit &lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt; — retrying with CLAUDE_CODE_OAUTH_TOKEN, DEGRADED: no Gmail/Calendar/Drive"&lt;/span&gt;
  &lt;span class="nv"&gt;CLAUDE_CODE_OAUTH_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OAT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; claude &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="nt"&gt;--permission-mode&lt;/span&gt; bypassPermissions &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;wrote_artifact &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAILED (exit &lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;): no artifact written"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token still earns its place — it's the difference between a degraded run and no run — it just isn't allowed to be the default. And the one-line verification, which is what I should have run on day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# run this through the SAME wrapper the scheduler uses, not from your shell&lt;/span&gt;
claude mcp list | &lt;span class="nb"&gt;grep &lt;/span&gt;claude.ai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it matters, and the traps next door
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The connector rule generalises past &lt;code&gt;setup-token&lt;/code&gt;.&lt;/strong&gt; Anything you set to make a headless run authenticate — an &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; for billing separation, an &lt;code&gt;apiKeyHelper&lt;/code&gt; pulling short-lived creds from a vault, a Bedrock switch — takes your connectors with it. The docs note the same for Anthropic profiles: "Features that need your claude.ai login, such as claude.ai connectors and &lt;code&gt;/schedule&lt;/code&gt;, aren't available while one of these sources is selected." If a scheduled agent needs Gmail or Calendar, it needs the subscription login and nothing above it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify under the job's environment, not yours.&lt;/strong&gt; Every check I ran interactively passed, because interactively the token wasn't set. &lt;code&gt;claude mcp list&lt;/code&gt; piped through the same wrapper the scheduler invokes is a different command than the one you type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic's own answer for this shape of work is &lt;a href="https://code.claude.com/docs/en/routines" rel="noopener noreferrer"&gt;routines&lt;/a&gt;&lt;/strong&gt;, not cron — scheduled cloud runs that carry your connectors by default. &lt;code&gt;/schedule&lt;/code&gt; requires a claude.ai subscription login for exactly the reason above. If your scheduled job's whole value is the connectors, that's the shorter path; I keep mine local because it needs the machine's filesystem and a private repo clone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit 0 is not success — name the artifact.&lt;/strong&gt; This is the durable one, and it isn't about Claude Code. Every unattended agent needs a success criterion expressed as a fact about the world: a file written after the run started, a row in a table, a message delivered. Process exit status tells you the interpreter didn't crash. Ask the filesystem instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A degraded mode must announce itself.&lt;/strong&gt; The fallback path prints &lt;code&gt;DEGRADED: no Gmail/Calendar/Drive&lt;/code&gt; into a log a human eventually reads, and the agent reports its actual coverage in its output. A silent fallback is just the original bug with more steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version note.&lt;/strong&gt; The &lt;code&gt;claude mcp list&lt;/code&gt; behaviour above is my observation on Claude Code 2.1.22x (August 2026) — the docs describe connector visibility in terms of &lt;code&gt;/mcp&lt;/code&gt; and &lt;code&gt;/status&lt;/code&gt;, not &lt;code&gt;claude mcp list&lt;/code&gt;, so treat the exact output as illustrative. The underlying rule is documented and stable. Separately, there are community reports of connectors also going missing in &lt;code&gt;-p&lt;/code&gt; mode with a perfectly good login (&lt;a href="https://github.com/anthropics/claude-code/issues/36060" rel="noopener noreferrer"&gt;#36060&lt;/a&gt;, &lt;a href="https://github.com/anthropics/claude-code/issues/43298" rel="noopener noreferrer"&gt;#43298&lt;/a&gt;) — a different mechanism from this one, and worth ruling out before you blame your auth.&lt;/p&gt;

&lt;p&gt;This scheduled job files commitments for an all-electric charter catamaran that doesn't exist yet — the agent stack around it is public at &lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;&lt;code&gt;naturali-agents&lt;/code&gt;&lt;/a&gt;. The boat is the reason the "did anything actually happen" question matters more than the exit code: nobody is watching the log at 06:05.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-04-launchd-minimal-path-breaks-mcp-servers-uv-command-not-found%20"&gt;launchd's minimal PATH breaks MCP servers: uv command not found&lt;/a&gt; — the same class of trap one layer down, where the scheduler's environment silently isn't yours; &lt;a href="https://dev.to%20post_url%202026-06-23-mcp-tools-not-showing-up-isolate-the-server-before-blaming-the-agent%20"&gt;When MCP tools break, isolate the server before blaming the agent&lt;/a&gt; — the debugging order for missing tools; and &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt; — the dead-man's-switch pattern this heartbeat is an instance of.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>mcp</category>
      <category>agents</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your service worker's precache can't do Range requests — PMTiles needs 206</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 01 Sep 2026 16:04:38 +0000</pubDate>
      <link>https://dev.to/clarkbw--/your-service-workers-precache-cant-do-range-requests-pmtiles-needs-206-1dao</link>
      <guid>https://dev.to/clarkbw--/your-service-workers-precache-cant-do-range-requests-pmtiles-needs-206-1dao</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Workbox's precache stores and replays a full &lt;code&gt;200 OK&lt;/code&gt;. &lt;code&gt;pmtiles&lt;/code&gt;&lt;br&gt;
reads the archive with HTTP Range requests and requires &lt;code&gt;206 Partial&lt;br&gt;
Content&lt;/code&gt;, so a precached &lt;code&gt;.pmtiles&lt;/code&gt; renders fine on a cold first visit — when&lt;br&gt;
no service worker is in the path yet — and throws on every visit after that.&lt;br&gt;
Take &lt;code&gt;.pmtiles&lt;/code&gt; out of &lt;code&gt;globPatterns&lt;/code&gt; and serve it from a &lt;code&gt;CacheFirst&lt;/code&gt;&lt;br&gt;
&lt;code&gt;runtimeCaching&lt;/code&gt; route with &lt;code&gt;rangeRequests: true&lt;/code&gt;, plus &lt;strong&gt;one non-ranged warm&lt;br&gt;
fetch&lt;/strong&gt;, because a ranged request can never populate that cache on its own.&lt;br&gt;
Jump to the fix. Repo:&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;slackwater-web&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;Slackwater&lt;/a&gt; is an offline&lt;br&gt;
tide and current app for the Salish Sea — a PWA that computes predictions on&lt;br&gt;
device so it still works with no signal, which is the only condition that&lt;br&gt;
matters when you're actually in Dodd Narrows. Its map screen draws a coastline&lt;br&gt;
from a committed 4.78 MB PMTiles archive (&lt;code&gt;public/land.pmtiles&lt;/code&gt;, OSM land&lt;br&gt;
polygons clipped to the Salish Sea, z0–14) rendered by MapLibre. No tile&lt;br&gt;
server, no network.&lt;/p&gt;

&lt;p&gt;Getting that archive to survive offline took two tries, and the failure mode of&lt;br&gt;
the first one is the entire post: &lt;strong&gt;it worked on the first visit and broke on&lt;br&gt;
every visit after.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: the map draws once, then never again
&lt;/h2&gt;

&lt;p&gt;The obvious way to make a file available offline in a &lt;code&gt;vite-plugin-pwa&lt;/code&gt; app is&lt;br&gt;
to precache it. That's one glob entry:&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;// vite.config.ts — the first attempt&lt;/span&gt;
&lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;globPatterns&lt;/span&gt;&lt;span class="p"&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;**/*.{js,css,html,svg,png,json,woff2,pmtiles}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// land.pmtiles is 4.78 MB; the default 2 MB cap would silently skip it&lt;/span&gt;
  &lt;span class="c1"&gt;// and the map would have no land offline.&lt;/span&gt;
  &lt;span class="nx"&gt;maximumFileSizeToCacheInBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&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;Load the app in a fresh tab: coastline draws. Reload it: blank map, and this in&lt;br&gt;
the console —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Server returned no content-length header or content-length exceeding
request. Check that your storage backend supports HTTP Byte Serving.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string is thrown by &lt;code&gt;pmtiles&lt;/code&gt;' own &lt;code&gt;FetchSource.getBytes&lt;/code&gt;, and it is&lt;br&gt;
&lt;em&gt;correct&lt;/em&gt;. Something in the path really did return the whole file instead of&lt;br&gt;
the slice it asked for. It just wasn't the storage backend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Ffirst-visit-vs-return-visit.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Ffirst-visit-vs-return-visit.svg" alt="The same pmtiles byte-range read gets 206 Partial Content from the static host on a first visit and a plain 200 with the full body from the Workbox precache on every return visit, which is what makes pmtiles throw."&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis: precache is 200-shaped, and it only joins the path on visit two
&lt;/h2&gt;

&lt;p&gt;Two facts collide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A precache response is always a full 200.&lt;/strong&gt; Workbox precaches by&lt;br&gt;
&lt;code&gt;cache.put()&lt;/code&gt;-ing complete responses, and the Cache API flatly refuses to store&lt;br&gt;
a partial one — try it and you get&lt;br&gt;
&lt;code&gt;TypeError: Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported&lt;/code&gt;.&lt;br&gt;
So the precache route can only ever answer with the entire body and status 200.&lt;br&gt;
It doesn't read the &lt;code&gt;Range&lt;/code&gt; header; it has nothing to slice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. pmtiles treats a 200 as a hard error, deliberately.&lt;/strong&gt; Here's the check, from&lt;br&gt;
&lt;a href="https://github.com/protomaps/PMTiles/blob/main/js/src/index.ts" rel="noopener noreferrer"&gt;the pmtiles source&lt;/a&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="c1"&gt;// some well-behaved backends, e.g. DigitalOcean CDN, respond with 200 instead of 206&lt;/span&gt;
&lt;span class="c1"&gt;// but we also need to detect no support for Byte Serving which is returning the whole file&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Length&lt;/span&gt;&lt;span class="dl"&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contentLength&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;contentLength&lt;/span&gt; &lt;span class="o"&gt;&amp;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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Server returned no content-length header or content-length exceeding request. Check that your storage backend supports HTTP Byte Serving.&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;A 200 whose &lt;code&gt;Content-Length&lt;/code&gt; exceeds what was requested means byte serving isn't&lt;br&gt;
working — pmtiles aborts rather than silently downloading a multi-gigabyte&lt;br&gt;
archive to read a 16 KB directory. Against a Workbox precache entry that's&lt;br&gt;
exactly what it sees, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the timing is what hid it.&lt;/strong&gt; A service worker only controls navigations&lt;br&gt;
that &lt;em&gt;start after&lt;/em&gt; it activates. On a first-ever visit the SW registers,&lt;br&gt;
installs and activates, but the page that installed it was never controlled —&lt;br&gt;
so &lt;code&gt;/land.pmtiles&lt;/code&gt; went straight to the static host, which handles Range&lt;br&gt;
properly and answered &lt;code&gt;206&lt;/code&gt;. Everything worked. On the next load the SW is in&lt;br&gt;
the path, the precache answers, and it throws.&lt;/p&gt;

&lt;p&gt;The bug is invisible in exactly the state you test in (fresh incognito tab,&lt;br&gt;
hard reload after a deploy) and present in the only state a user is ever in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried, and why it failed
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Attempt 1 — precache it and raise the size cap
&lt;/h3&gt;

&lt;p&gt;The config above. Worth keeping the sub-lesson even though the approach was&lt;br&gt;
wrong: &lt;code&gt;maximumFileSizeToCacheInBytes&lt;/code&gt; defaults to &lt;strong&gt;2 MB&lt;/strong&gt;, and a file over&lt;br&gt;
that is dropped from the precache manifest with a build-log line and &lt;em&gt;no&lt;/em&gt;&lt;br&gt;
runtime error. The app just quietly has no offline copy. If you precache&lt;br&gt;
anything big — a tile archive, a model, a wasm blob — check the manifest, don't&lt;br&gt;
assume the glob matched.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 2 — a smoke test that agreed with us
&lt;/h3&gt;

&lt;p&gt;The offline smoke check was green throughout. It was green because it had been&lt;br&gt;
written to be:&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="c1"&gt;// M5: /map opens the discovery map directly. This has to run as the very&lt;/span&gt;
&lt;span class="c1"&gt;// first navigation, before anything else on this origin installs the PWA&lt;/span&gt;
&lt;span class="c1"&gt;// service worker: once the SW is controlling, its default Workbox precache&lt;/span&gt;
&lt;span class="c1"&gt;// route serves the precached land.pmtiles as a plain 200 with no&lt;/span&gt;
&lt;span class="c1"&gt;// Range/Content-Length support, and pmtiles' own byte-serving check throws&lt;/span&gt;
&lt;span class="c1"&gt;// on that — a real bug (land.pmtiles needs workbox-range-requests wired to&lt;/span&gt;
&lt;span class="c1"&gt;// its precache route to survive a returning/offline visit), but out of scope&lt;/span&gt;
&lt;span class="c1"&gt;// for this smoke-only task.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment names the bug, in full, and then orders the test to avoid it. A&lt;br&gt;
passing check is a claim about what ran, not about what's true — and here the&lt;br&gt;
claim was "the cold path works", which was never in doubt.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 3 — runtimeCaching with rangeRequests, and an empty cache
&lt;/h3&gt;

&lt;p&gt;The documented answer is a &lt;code&gt;runtimeCaching&lt;/code&gt; route with &lt;code&gt;rangeRequests: true&lt;/code&gt;,&lt;br&gt;
which wires up&lt;br&gt;
&lt;a href="https://developer.chrome.com/docs/workbox/modules/workbox-range-requests" rel="noopener noreferrer"&gt;&lt;code&gt;workbox-range-requests&lt;/code&gt;&lt;/a&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="nx"&gt;runtimeCaching&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="na"&gt;urlPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CacheFirst&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;land-pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rangeRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;cacheableResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ship that alone and the map still doesn't work offline — and now&lt;br&gt;
&lt;code&gt;caches.open("land-pmtiles")&lt;/code&gt; is &lt;em&gt;empty&lt;/em&gt;. Nothing was ever stored.&lt;/p&gt;

&lt;p&gt;The reason is in the plugin's one and only callback:&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;// workbox-range-requests/src/RangeRequestsPlugin.ts&lt;/span&gt;
&lt;span class="nx"&gt;cachedResponseWillBeUsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkboxPlugin&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cachedResponseWillBeUsed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;cachedResponse&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="c1"&gt;// Only return a sliced response if there's something valid in the cache,&lt;/span&gt;
  &lt;span class="c1"&gt;// and there's a Range: header in the request.&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;cachedResponse&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createPartialResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cachedResponse&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// If there was no Range: header, or if cachedResponse wasn't valid, just&lt;/span&gt;
  &lt;span class="c1"&gt;// pass it through as-is.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cachedResponse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cachedResponseWillBeUsed&lt;/code&gt; — that's it. The plugin never touches the outbound&lt;br&gt;
request. It slices a response &lt;em&gt;you already have&lt;/em&gt;; it has no opinion on how you&lt;br&gt;
got one.&lt;/p&gt;

&lt;p&gt;So the first request to reach the route is pmtiles' own, carrying &lt;code&gt;Range:&lt;br&gt;
bytes=0-16383&lt;/code&gt;. &lt;code&gt;CacheFirst&lt;/code&gt; misses, goes to the network, and the origin&lt;br&gt;
correctly answers &lt;code&gt;206&lt;/code&gt;. Then &lt;code&gt;cacheableResponse: { statuses: [200] }&lt;/code&gt; refuses&lt;br&gt;
to store it — and you can't widen that to &lt;code&gt;[200, 206]&lt;/code&gt;, because the Cache API&lt;br&gt;
rejects a 206 on &lt;code&gt;put()&lt;/code&gt; anyway. The cache stays empty, the next read misses&lt;br&gt;
again, and the loop never closes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Fcachefirst-range-deadlock.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Fcachefirst-range-deadlock.svg" alt="A ranged request can never populate a CacheFirst route because the origin answers 206 and cacheableResponse only stores 200, so the cache stays empty forever; one non-ranged warm fetch stores the full 200 that RangeRequestsPlugin then slices into 206 partials."&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two pieces: the route, and one plain fetch that primes 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="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pmtiles is deliberately NOT globbed here: a precache entry is a plain 200&lt;/span&gt;
  &lt;span class="c1"&gt;// with the full body and no Range support, but pmtiles reads the archive&lt;/span&gt;
  &lt;span class="c1"&gt;// with Range requests and needs 206.&lt;/span&gt;
  &lt;span class="nl"&gt;globPatterns&lt;/span&gt;&lt;span class="p"&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;**/*.{js,css,html,svg,png,json}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;runtimeCaching&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="na"&gt;urlPattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CacheFirst&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;land-pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;rangeRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// serve 206 partials from the cached full body&lt;/span&gt;
        &lt;span class="na"&gt;cacheableResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.tsx — the warm fetch, without which the cache above is never filled&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&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;warm&lt;/span&gt; &lt;span class="o"&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/land.pmtiles&lt;/span&gt;&lt;span class="dl"&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="o"&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;warm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;else&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;serviceWorker&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="s2"&gt;controllerchange&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;fetch&lt;/code&gt; carries no &lt;code&gt;Range&lt;/code&gt; header, so it gets a full &lt;code&gt;200&lt;/code&gt;, which&lt;br&gt;
&lt;code&gt;cacheableResponse&lt;/code&gt; accepts and stores. From then on every ranged read that&lt;br&gt;
pmtiles issues hits the cache and &lt;code&gt;RangeRequestsPlugin&lt;/code&gt; slices the stored body&lt;br&gt;
into the &lt;code&gt;206&lt;/code&gt; it wanted — offline, forever.&lt;/p&gt;

&lt;p&gt;The one-line version: &lt;strong&gt;&lt;code&gt;rangeRequests: true&lt;/code&gt; is a read path, not a write path.&lt;br&gt;
You still have to get a whole 200 into the cache yourself.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;serviceWorker.ready&lt;/code&gt; does not mean the service worker controls your page
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ready&lt;/code&gt; resolves once there's an &lt;em&gt;active&lt;/em&gt; registration for the scope. It says&lt;br&gt;
nothing about &lt;code&gt;navigator.serviceWorker.controller&lt;/code&gt;, and with&lt;br&gt;
&lt;code&gt;registerType: 'prompt'&lt;/code&gt; those two diverge on the first load: &lt;code&gt;'prompt'&lt;/code&gt; does&lt;br&gt;
not set &lt;code&gt;clientsClaim&lt;/code&gt;, so the page that installed the worker is never&lt;br&gt;
controlled. Fire the warm fetch on &lt;code&gt;ready&lt;/code&gt; alone and on a first-ever visit it&lt;br&gt;
goes straight to the network, bypasses the route entirely, and caches nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Fservice-worker-control-timeline.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fpmtiles-workbox-precache-range-requests-206-partial-content-service-worker-offline-maplibre-vite-plugin-pwa-runtimecaching-rangerequests-cachefirst%2Fservice-worker-control-timeline.svg" alt="With registerType prompt there is no clientsClaim, so serviceWorker.ready resolves on the first load while controller is still null and the warm fetch is skipped; only the next controlled load has a controller and populates the cache."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hence the two-branch guard: run it now if &lt;code&gt;controller&lt;/code&gt; is already set,&lt;br&gt;
otherwise wait for &lt;code&gt;controllerchange&lt;/code&gt; — which is what fires when a worker&lt;br&gt;
&lt;em&gt;does&lt;/em&gt; claim, whether that's &lt;code&gt;registerType: 'autoUpdate'&lt;/code&gt; (vite-plugin-pwa&lt;br&gt;
forces &lt;code&gt;clientsClaim&lt;/code&gt; and &lt;code&gt;skipWaiting&lt;/code&gt; for that mode) or an update takeover&lt;br&gt;
later in the session.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test the controlled path, because that's the one users are on
&lt;/h3&gt;

&lt;p&gt;The rewritten smoke check stops dodging the service worker and goes looking for&lt;br&gt;
it: load online, wait for the SW, &lt;strong&gt;reload to gain control&lt;/strong&gt;, wait for the&lt;br&gt;
cache entry to actually exist, then cut the network and reload again.&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;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;map`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&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;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// NOW the active SW controls the page, so the warm fetch and the map's own&lt;/span&gt;
&lt;span class="c1"&gt;// pmtiles reads go through the runtime Range route.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&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;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controller&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&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;await &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;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;land-pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/land.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&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;mapCdp&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createCDPSession&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;mapCdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Network.enable&lt;/span&gt;&lt;span class="dl"&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;mapCdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Network.emulateNetworkConditions&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="na"&gt;offline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;downloadThroughput&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="na"&gt;uploadThroughput&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="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// This is the assertion the fix exists for.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waiting on &lt;code&gt;.maplibregl-canvas&lt;/code&gt; rather than the container &lt;code&gt;div&lt;/code&gt; matters —&lt;br&gt;
the container mounts whether or not MapLibre ever got a WebGL context up. And&lt;br&gt;
don't trust &lt;code&gt;navigator.onLine&lt;/code&gt; to tell you the emulation took: under CDP&lt;br&gt;
offline it lies. Prove the network is down with a real &lt;code&gt;fetch()&lt;/code&gt; to an&lt;br&gt;
unreachable origin first, and attach your error listeners &lt;em&gt;after&lt;/em&gt; that probe,&lt;br&gt;
or Chrome's own resource-load error for the probe lands in your assertions.&lt;/p&gt;

&lt;h3&gt;
  
  
  The nice side effect
&lt;/h3&gt;

&lt;p&gt;Once &lt;code&gt;.pmtiles&lt;/code&gt; left the precache, the largest precached chunk was the map&lt;br&gt;
screen's ~1 MB of JS — comfortably under the 2 MB default — so the&lt;br&gt;
&lt;code&gt;maximumFileSizeToCacheInBytes&lt;/code&gt; bump could go away. Fewer knobs is the correct&lt;br&gt;
outcome of a fix.&lt;/p&gt;




&lt;p&gt;Slackwater is the offline tide app I want in my pocket on an all-electric&lt;br&gt;
charter catamaran with no cell signal, so "works on a return visit" isn't a&lt;br&gt;
nice-to-have. Code: &lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;slackwater-web&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;Porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; — the prediction engine behind this same app; and &lt;a href="https://dev.to%20post_url%202026-08-01-persistent-queue-http-reporter-offline-catch-up-flaky-boat-internet-jsonl-write-through-restart-proof-400-404-drop-5xx-retry-cap-signalk-dsc-dscwatch%20"&gt;A persistent-queue HTTP reporter for distress traffic over flaky internet&lt;/a&gt; — offline-first on the other side of the wire.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>pwa</category>
      <category>serviceworker</category>
      <category>workbox</category>
      <category>pmtiles</category>
    </item>
    <item>
      <title>A passing check is a claim about what ran, not what's true</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Mon, 31 Aug 2026 16:04:53 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-passing-check-is-a-claim-about-what-ran-not-whats-true-p0d</link>
      <guid>https://dev.to/clarkbw--/a-passing-check-is-a-claim-about-what-ran-not-whats-true-p0d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A green check tells you a code path ran and produced the value it&lt;br&gt;
was compared against. It does not tell you the subject was examined. Six&lt;br&gt;
independent bugs in one month, across four unrelated codebases, were all the&lt;br&gt;
same shape: something reported success for a region it never looked at.&lt;br&gt;
Jump to the checklist.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I hit this six times in a month. Different languages, different repos, no shared&lt;br&gt;
code: a JavaScript data package, a Python MCP server, a shell wrapper, a&lt;br&gt;
TypeScript PWA. Every one of them produced a clean result for something that was&lt;br&gt;
never actually examined — not a wrong answer, an &lt;em&gt;absent&lt;/em&gt; answer, dressed as a&lt;br&gt;
pass.&lt;/p&gt;

&lt;p&gt;That's a nastier failure than a wrong answer. A wrong answer is a signal. An&lt;br&gt;
absent answer that reports itself as a pass is silence you've been trained to&lt;br&gt;
read as safety.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fa-passing-check-that-never-ran-false-green-tests-one-sided-validator-benchmark-scores-tool-called-not-result-puppeteer-asserted-too-early-jsdom-no-webgl%2Fblind-spots.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fa-passing-check-that-never-ran-false-green-tests-one-sided-validator-benchmark-scores-tool-called-not-result-puppeteer-asserted-too-early-jsdom-no-webgl%2Fblind-spots.svg" alt="Six green checks from one month, each with the region of its subject it never examined: outside the coastline clip, entries absent from the lock file, what the tool returned, the plain-list warning format, the two seconds after the canvas appeared, and anything needing WebGL." width="880" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here they are, shortest first, each with the rule I'd generalize out of it.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. A boolean that returns false for "no" and for "I can't tell"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;&lt;code&gt;station-corrections&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
exists to audit the published positions of tide and current stations. Its&lt;br&gt;
headline check: is this station's coordinate sitting on land? That's a&lt;br&gt;
point-in-polygon test against a bundled coastline:&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="c1"&gt;// src/coastline.js — before&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;isOnLand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lon&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;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;point&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lat&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;coastline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;features&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;feature&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;booleanPointInPolygon&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;feature&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coastline ships clipped to a bounding box — the full planet coastline is far&lt;br&gt;
too large to bundle. Outside that box there are no polygons, so &lt;code&gt;some()&lt;/code&gt; finds&lt;br&gt;
nothing and the function returns &lt;code&gt;false&lt;/code&gt;. Which is byte-identical to the answer&lt;br&gt;
it returns for a station verified to be in open water.&lt;/p&gt;

&lt;p&gt;Three CHS current gates — Blackney Passage, central Johnstone Strait, Weynton&lt;br&gt;
Passage — sat north of the clip. The audit reported them valid. It had never&lt;br&gt;
looked at them. And the registry was growing north, so the silent region was&lt;br&gt;
growing too.&lt;/p&gt;

&lt;p&gt;The fix is a coverage predicate derived from the data rather than hardcoded, so&lt;br&gt;
rebuilding the coastline with a different clip updates it for free:&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="c1"&gt;// src/coastline.js — after&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;isWithinCoverage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lon&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;coverageBounds&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// walked off the bundled polygons, memoized&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minLat&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxLat&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minLon&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxLon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the second-order fix, which matters more: a registry station outside&lt;br&gt;
coverage became a validation &lt;strong&gt;failure&lt;/strong&gt;, not a note — and the coastline build&lt;br&gt;
script now derives its clip box from the registry's own extent plus a margin,&lt;br&gt;
floored at the proven box so it only ever grows outward. The clip can no longer&lt;br&gt;
fall behind the data it's meant to check&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/station-corrections/commit/7d52f67" rel="noopener noreferrer"&gt;&lt;code&gt;7d52f67&lt;/code&gt;&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/station-corrections/commit/1c06273" rel="noopener noreferrer"&gt;&lt;code&gt;1c06273&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; a predicate over partial data needs three states, not two. If&lt;br&gt;
"no" and "I have no data here" collapse to the same value, every caller that&lt;br&gt;
reads &lt;code&gt;false&lt;/code&gt; as a verdict is reporting a result it never computed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  2. A validator that iterates one side of a pair
&lt;/h2&gt;

&lt;p&gt;Same package, next day. Station slugs are public URL segments, so a rename is a&lt;br&gt;
breaking change. A lock file pins the slug each station last shipped with, and&lt;br&gt;
&lt;code&gt;check-slugs&lt;/code&gt; runs in CI ahead of the data checks:&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="c1"&gt;// src/slugs-lock.js — before&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lockedSlug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slugs&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;nowSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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;nowSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;nowSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lockedSlug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...fail if the slug moved without the old value in formerSlugs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the loop bound. It iterates &lt;strong&gt;the lock&lt;/strong&gt;. A station present in the data but&lt;br&gt;
absent from the lock is never visited — so a brand-new station's slug entered&lt;br&gt;
the public API with nothing pinned to compare against, and that slug plus every&lt;br&gt;
future change to it passed green forever. The one thing the lock exists to&lt;br&gt;
prevent was invisible for exactly the stations most likely to get renamed: the&lt;br&gt;
new ones.&lt;/p&gt;

&lt;p&gt;The guard is now symmetric — the lock must reflect the current knowable slugs&lt;br&gt;
exactly, in both directions:&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="c1"&gt;// src/slugs-lock.js — after&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lockedSlug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slugs&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;nowSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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;nowSlug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: in the slug lock but no longer in the data — its slug "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lockedSlug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is dead`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...moved-slug check unchanged&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slugs&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: slug "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" is not in the lock — run &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;station-corrections slugs&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit message named the pattern out loud, because it landed a day after the&lt;br&gt;
coastline one: &lt;em&gt;"Same failure class as the coverage bug: a clean result for&lt;br&gt;
something never checked"&lt;/em&gt;&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/station-corrections/commit/99afa20" rel="noopener noreferrer"&gt;&lt;code&gt;99afa20&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; a validator comparing two collections must iterate their &lt;strong&gt;union&lt;/strong&gt;.&lt;br&gt;
Iterating one side can only ever find disagreements about things both sides&lt;br&gt;
already know about — which is the easy half of the problem.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  3. A benchmark that scored the call, not the result
&lt;/h2&gt;

&lt;p&gt;I run a benchmark over the boat agent's MCP tool routing: a set of golden asks,&lt;br&gt;
each with the tools a correct answer requires. Scoring:&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="c1"&gt;# poseidon/bench/scoring.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observed_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;observed_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected_tools&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;issubset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observed_tools&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="c1"&gt;# ...optional argument matching
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set containment on tool names. It measures routing — did the model reach for&lt;br&gt;
&lt;code&gt;battery_state&lt;/code&gt; when asked about the batteries — and routing is genuinely what&lt;br&gt;
the benchmark was built to compare across models. But nothing in that function&lt;br&gt;
ever opens the payload.&lt;/p&gt;

&lt;p&gt;Meanwhile, in &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;battery_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SignalKClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&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="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&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;electrical.batteries.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bank&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"0"&lt;/code&gt; is the SignalK instance convention, and it's a perfectly reasonable&lt;br&gt;
default — except the vessel publishes &lt;code&gt;electrical.batteries.house&lt;/code&gt;. So the bare&lt;br&gt;
tool returned this, on a boat that had been publishing battery data the entire&lt;br&gt;
time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"bank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"soc_fraction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"voltage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right tool, right arguments, no data. Score: match. The agent only got a real&lt;br&gt;
reading if the person asking happened to say the word "house". This survived&lt;br&gt;
months of green benchmark runs and was found by hand, mid-benchmark, reading a&lt;br&gt;
transcript for an unrelated reason&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/signalk-mcp/commit/e837d92" rel="noopener noreferrer"&gt;&lt;code&gt;e837d92&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The tool fix is small — try &lt;code&gt;"0"&lt;/code&gt;, then discover the vessel's own banks off the&lt;br&gt;
&lt;code&gt;electrical.batteries&lt;/code&gt; subtree, and never second-guess an explicitly named bank&lt;br&gt;
because answering about a different battery is worse than answering "no data".&lt;br&gt;
The interesting fix is the scoring one: at least one assertion per ask has to&lt;br&gt;
reach into the returned payload.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; a score computed from the call examines your harness's routing. A&lt;br&gt;
score computed from the result examines the system. If nothing in your&lt;br&gt;
scorer opens the response body, a tool that returns nulls forever is a&lt;br&gt;
perfect performer.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  4. A selftest that greps for a string the command never prints
&lt;/h2&gt;

&lt;p&gt;A CLI wrapper on the boat gateway sources its secrets and execs the real&lt;br&gt;
binary. Scripts parse its output, so a missing environment variable has to fail&lt;br&gt;
loudly and early — hence a &lt;code&gt;--selftest&lt;/code&gt; that proves the secrets resolved on a&lt;br&gt;
real invocation.&lt;/p&gt;

&lt;p&gt;The trap: the CLI reports the same condition in two different formats depending&lt;br&gt;
on the subcommand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mytool audit
&lt;span class="go"&gt;┌ Config warnings ──────────────────────────────┐
│ ! api.token: Missing env var API_TOKEN        │
└───────────────────────────────────────────────┘

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mytool config validate
&lt;span class="go"&gt;1 warning(s): ! api.token: Missing env var API_TOKEN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selftest called &lt;code&gt;config validate&lt;/code&gt; and grepped for the box title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# before — passed while the token was genuinely missing&lt;/span&gt;
&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;mytool config validate 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"Config warnings"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SELFTEST FAIL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SELFTEST PASS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config validate&lt;/code&gt; never prints &lt;code&gt;Config warnings&lt;/code&gt;. The grep could not match. The&lt;br&gt;
selftest passed, cleanly and instantly, with a required token genuinely absent&lt;br&gt;
— a check whose entire job was catching that exact state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# after — match the thing both formats actually contain&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"missing env var"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What caught it wasn't review and wasn't a test. It was running the negative&lt;br&gt;
control: deliberately unsetting the variable and watching whether the selftest&lt;br&gt;
went red. It didn't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; you have not tested a check until you have watched it fail. Break&lt;br&gt;
the thing on purpose, confirm red, put it back, confirm green. A selftest&lt;br&gt;
that passes when broken is worse than no selftest — it converts an unknown&lt;br&gt;
into a false known.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  5. An assertion that fired before the system finished failing
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;&lt;code&gt;slackwater-web&lt;/code&gt;&lt;/a&gt; is an&lt;br&gt;
offline-first tide PWA, so its Puppeteer smoke suite loads the map with the&lt;br&gt;
network cut and asserts no unexpected console errors. It looked like this:&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;await&lt;/span&gt; &lt;span class="nx"&gt;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapErrors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;   &lt;span class="c1"&gt;// green&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MapLibre puts a canvas in the DOM as soon as it initialises the GL context —&lt;br&gt;
well before it has finished trying, and failing, to fetch its tiles and glyphs&lt;br&gt;
over a network that isn't there. The assertion fired into an empty array&lt;br&gt;
roughly two seconds early. Green, for the wrong reason.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fa-passing-check-that-never-ran-false-green-tests-one-sided-validator-benchmark-scores-tool-called-not-result-puppeteer-asserted-too-early-jsdom-no-webgl%2Fsettle-then-judge.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fa-passing-check-that-never-ran-false-green-tests-one-sided-validator-benchmark-scores-tool-called-not-result-puppeteer-asserted-too-early-jsdom-no-webgl%2Fsettle-then-judge.svg" alt="Timeline showing the canvas element appearing about two seconds before the map finishes failing its external tile fetches, so the old assertion fired into an empty error list and passed, while the fixed version settles first and then judges." width="880" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix separates collection from judgement — a watcher that starts before the&lt;br&gt;
navigation and a &lt;code&gt;settle()&lt;/code&gt; that drains every pending handle before anything is&lt;br&gt;
asserted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;settleMapErrors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapPage&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/map`&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;mapPage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.map-canvas .maplibregl-canvas&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="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// The canvas appears well before the map has finished failing its external&lt;/span&gt;
&lt;span class="c1"&gt;// fetches, so asserting the instant it exists passed for the wrong reason.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mapErrors&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;settleMapErrors&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mapErrors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The same commit fixed why those errors were unreadable in the first place:&lt;br&gt;
Puppeteer's &lt;code&gt;msg.text()&lt;/code&gt; renders an object argument as its class name, so&lt;br&gt;
MapLibre's minified &lt;code&gt;AJAXError&lt;/code&gt; arrived as &lt;code&gt;[object Ae]&lt;/code&gt; with the host it named&lt;br&gt;
invisible to the noise classifier. Resolving each argument through its&lt;br&gt;
&lt;code&gt;JSHandle&lt;/code&gt; makes collection async, which is what forces the settle-then-judge&lt;br&gt;
shape anyway —&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web/commit/32bfe09" rel="noopener noreferrer"&gt;&lt;code&gt;32bfe09&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; an assertion is a claim about an instant. If the system is still&lt;br&gt;
working when you sample, you measured a different system. Collect over a&lt;br&gt;
window, settle, then judge — and be suspicious of any wait keyed on the&lt;br&gt;
&lt;em&gt;first&lt;/em&gt; sign of life rather than the last.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  6. When deleting the fix leaves every check green
&lt;/h2&gt;

&lt;p&gt;Same app, same evening. &lt;code&gt;resolvePinStates&lt;/code&gt; called with no dependency object let&lt;br&gt;
every unsynced station fall through to a live upstream fetch — a request storm&lt;br&gt;
on every map open, throttled behind a ~24 req/min limiter, with a single&lt;br&gt;
&lt;code&gt;setData&lt;/code&gt; gated on the slowest of them. In review the pins effectively never&lt;br&gt;
coloured. The fix is one argument: a rejecting &lt;code&gt;fetchFn&lt;/code&gt; that makes state reads&lt;br&gt;
cache-only, so an unsynced station stays neutral, which is the honest unknown&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/slackwater-web/commit/afa3df1" rel="noopener noreferrer"&gt;&lt;code&gt;afa3df1&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Now: which check catches its removal?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsc&lt;/code&gt; — green. The argument is optional.&lt;/li&gt;
&lt;li&gt;The Vitest suite — green. jsdom has no WebGL, so the map component never
mounts and no test observes the call.&lt;/li&gt;
&lt;li&gt;The production build — green.&lt;/li&gt;
&lt;li&gt;The Puppeteer smoke test — green. A request storm against a third-party API
is not a console error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Delete the fix and all four stay green while the storm comes back. The bug is&lt;br&gt;
invisible to every conventional layer, which means writing a conventional test&lt;br&gt;
would be theatre. So the guard reads the source file as text:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resolves pin states cache-only — never fetching&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="c1"&gt;// Guarded by source text because nothing else can catch it: jsdom has no&lt;/span&gt;
  &lt;span class="c1"&gt;// WebGL so the map never mounts, and dropping the fetchFn leaves tsc, the&lt;/span&gt;
  &lt;span class="c1"&gt;// suite, the build and the smoke test all green while every map open fires&lt;/span&gt;
  &lt;span class="c1"&gt;// one live request per unsynced station at a third-party API.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;MapScreen.tsx&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;utf8&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;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&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;/resolvePinStates&lt;/span&gt;&lt;span class="se"&gt;\([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?\)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\n\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;then/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resolvePinStates call site not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeTruthy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetchFn&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;That test is ugly. It's coupled to formatting, it can't see a rename, and it&lt;br&gt;
proves nothing about behaviour. It is also the only thing in the repo that goes&lt;br&gt;
red when the fix is removed&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/slackwater-web/commit/88ec107" rel="noopener noreferrer"&gt;&lt;code&gt;88ec107&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; if you delete the fix and nothing goes red, the missing thing is the&lt;br&gt;
check, not the bug. A real guard with a known ceiling beats an elegant guard&lt;br&gt;
that doesn't exist — as long as the test says, in the test, why it's shaped&lt;br&gt;
that way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What they have in common
&lt;/h2&gt;

&lt;p&gt;None of these were flaky. None were wrong answers. Every one of them was a&lt;br&gt;
&lt;strong&gt;scope&lt;/strong&gt; bug in the check itself: the set of things examined was smaller than&lt;br&gt;
the set of things claimed, and nothing in the output distinguished the two.&lt;/p&gt;

&lt;p&gt;That's the whole thesis. A green check is a claim about what ran. Reading it as&lt;br&gt;
a claim about what's true requires an extra assumption — that the check's&lt;br&gt;
subject and the check's coverage are the same set — and that assumption is&lt;br&gt;
exactly what nobody verifies, because verifying it feels like testing the tests.&lt;/p&gt;

&lt;p&gt;Two structural tells show up in five of the six:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The check consumed a partial data source and had no way to say so.&lt;/strong&gt; The
clipped coastline, the lock file, the jsdom environment, the two-second
window. Partial coverage plus a boolean result is a machine for manufacturing
false confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody had ever watched it fail.&lt;/strong&gt; The negative control caught #4 within a
minute. It would have caught #1, #2 and #3 just as fast.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Six questions. They take a minute, and each one is a bug I actually shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does this validator iterate both sides of the pair?&lt;/strong&gt; Or does it loop over
one collection and silently exempt everything the other one knows about?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can this predicate return "no" and "I don't know" as the same value?&lt;/strong&gt; If
yes, every caller reading it as a verdict is reporting a result nobody
computed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the score examine the result, or just the call?&lt;/strong&gt; Set containment on
tool names, exit codes, "did it get called" — all of these pass on an empty
payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Did you run the negative control?&lt;/strong&gt; Break the thing, watch the check go
red, put it back. If you've never seen it fail, you don't know it can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the assertion fire after the system has finished, or at the first sign
of life?&lt;/strong&gt; Waiting on the first artifact to appear is not the same as waiting
for the work to finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you deleted the fix, which check would go red?&lt;/strong&gt; If the honest answer is
"none", write that check first — even if the only shape available is an ugly
one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's the difference between a test suite that tells you&lt;br&gt;
the code works and one that tells you the suite ran.&lt;/p&gt;




&lt;p&gt;I'm building the software stack for an all-electric charter catamaran — data&lt;br&gt;
packages, SignalK plugins, MCP servers, an offline tide app — mostly in public.&lt;br&gt;
Code for the bugs above lives in&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;station-corrections&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;signalk-mcp&lt;/a&gt; and&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;slackwater-web&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;Porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; — what a check looks like when it &lt;em&gt;does&lt;/em&gt; examine the result · &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt; — the same silence, one layer out · &lt;a href="//%20post_url%202026-08-06-benchmark-your-own-llm-workload-before-migrating-gpt-5.6-vs-claude-sonnet-mcp-tool-routing-reasoning-effort-none%20"&gt;Bench your own workload before you switch LLM vendors&lt;/a&gt; — the benchmark from vignette 3, before its scoring blind spot showed up&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ci</category>
      <category>javascript</category>
      <category>python</category>
    </item>
    <item>
      <title>A current station's coordinate is a label, not the hydraulic control</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Mon, 31 Aug 2026 14:11:48 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-current-stations-coordinate-is-a-label-not-the-hydraulic-control-gb2</link>
      <guid>https://dev.to/clarkbw--/a-current-stations-coordinate-is-a-label-not-the-hydraulic-control-gb2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — A published tide or current station coordinate is an &lt;em&gt;identifier for the place&lt;/em&gt;, not a measurement of where the physics happens. At Dodd Narrows the CHS current station sits 120 m from the pass's actual throat, on water twice the cross-sectional area. Any model that scales speed by continuity (&lt;code&gt;u ∝ 1/A&lt;/code&gt;) divides by the wrong area there and over-predicts the throat by 91%. Fix it once, in shared reference data, with a &lt;code&gt;source:&lt;/code&gt; field recording where the real coordinate came from. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fanchor-stability-before-after.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fanchor-stability-before-after.svg" alt="Moving the anchor from the published CHS coordinate to the 80 metre hydraulic control drops the anchor cross-section area swing from 19.9 and 20.5 per cent to 8.6 and 9.6 per cent on the two section-placement variants, while the chart-datum variant gets worse, 19.7 to 22.7 per cent." width="880" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;We build offline tidal-current prediction for the Salish Sea. Harmonic prediction at a &lt;em&gt;station&lt;/em&gt; is a solved problem — you fit constituents and evaluate them. Drawing a current &lt;em&gt;field&lt;/em&gt;, a speed and direction at arbitrary points near a pass, is not.&lt;/p&gt;

&lt;p&gt;The obvious source is a coarse ocean model. It is useless at rapids. Here is the coastal-ocean mesh against the fitted station truth at three BC/WA gates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gate               mesh cell    truth      mesh reads   ratio
Dodd Narrows       516 m        6.2 kn     1.46 kn      0.24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 516 m mesh cell cannot see an 80 m rock gut. It smears a 6-knot rapid into a 1.5-knot drift, and animating that would make bad data more persuasive than no data.&lt;/p&gt;

&lt;p&gt;So instead: grow a small patch of certified geometry around each gate. Take a 10 m bathymetric DEM, trace a thalweg through the pass, cut cross-sections every 50–200 m, and scale the station's own fitted speed along the reach by mass conservation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;|u|(x) = |u|_anchor · A_anchor / A(x)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One anchor station, one reach, one law. It certified cleanly at Tacoma Narrows against held-out NOAA truth in both directions (speed median 0.32 kn and 0.20 kn against a 0.5 kn bar, no flood/ebb sign flips). It certified at Seymour Narrows. At Dodd Narrows it fell over, and the failure did not look like a data-quality problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./sensitivity.py dodd-narrows
&lt;span class="go"&gt;anchor area swung 19.9% under variant 'shift+0.5' — anchor placement is sensitivity-unstable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That number is not prediction error. It is &lt;em&gt;anchor stability&lt;/em&gt;: perturb the section grid by half a spacing, or recompute areas at chart datum instead of mean water level, and see how much the anchor section's own cross-sectional area moves. Every scale in the patch is &lt;code&gt;A_anchor / A(x)&lt;/code&gt;, so a wobbly &lt;code&gt;A_anchor&lt;/code&gt; multiplies straight into every cell. Over 10% and the pass ships nothing.&lt;/p&gt;

&lt;p&gt;Dodd came in at &lt;strong&gt;19.91 / 20.52 / 19.67%&lt;/strong&gt; across the three variants. All three. Not marginal — double the bar, on every axis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;The tell was in a field the pipeline prints and nobody reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./sections.py dodd-narrows
&lt;span class="go"&gt;  kept_range [18, 18] of [0, 41] — throat 80.0 m (strip minimum (anchor is not the throat)),
  flare 1.3 → limit 104.0 m
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;anchor is not the throat&lt;/code&gt;. That string exists because the code has to decide what "the throat" is before it can decide where the patch ends:&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;FLARE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.3&lt;/span&gt;        &lt;span class="c1"&gt;# first section wider than FLARE x throat ends the patch
&lt;/span&gt;&lt;span class="n"&gt;THROAT_TOL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;   &lt;span class="c1"&gt;# anchor may exceed the strip minimum by this and still be "the throat"
&lt;/span&gt;
&lt;span class="n"&gt;min_w&lt;/span&gt; &lt;span class="o"&gt;=&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;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width_m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;throat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;width_m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# k = section nearest the anchor station
&lt;/span&gt;&lt;span class="n"&gt;throat_ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anchor section&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;throat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;min_w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;THROAT_TOL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;throat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;throat_ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;min_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strip minimum (anchor is not the throat)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normally the anchor &lt;em&gt;is&lt;/em&gt; the throat: you anchor at the rapid, the rapid is the narrowest water, &lt;code&gt;scale&lt;/code&gt; is 1.0 there by construction. Seymour Narrows behaves exactly that way — anchor section, 770 m, done.&lt;/p&gt;

&lt;p&gt;Dodd does not. The section nearest the published coordinate is &lt;strong&gt;160 m&lt;/strong&gt; wide. The narrowest section in the strip is &lt;strong&gt;80 m&lt;/strong&gt;, two sections away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fdodd-anchor-vs-control.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fdodd-anchor-vs-control.svg" alt="Plan view of Dodd Narrows: the published CHS current-station coordinate falls on a 160 metre wide transect of area 2322 square metres, 120 metres from the 80 metre hydraulic control whose area is 1216 square metres, so continuity scales the published 9.43 knots up to 18.01 knots at the gut." width="880" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things follow, and they are different bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The published rate is a throat number, so anchoring off the throat over-predicts.&lt;/strong&gt; The reach-mean cross-sectional areas are 2322 m² at the published coordinate and 1216 m² at the gut. CHS publishes ~9.43 kn as Dodd's spring maximum — that is the &lt;em&gt;fastest water in the pass&lt;/em&gt;, which is the water at the control. Feed it in at a section with 1.9× the area and continuity dutifully scales it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9.43 kn × 2322 / 1216 = 18.01 kn at the gut
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen knots. Dodd is a nine-knot rapid. The model is not wrong; the anchor is in the wrong place, so the model is answering a question nobody asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The published coordinate sits on the flare, where geometry is unstable.&lt;/strong&gt; Section 18 is on the shoulder where the channel opens out. Nudge the section grid 25 m and the area at that station moves a lot; do the same at the gut and it barely moves. That is where the 19.9% comes from.&lt;/p&gt;

&lt;p&gt;Now the part that took the longest to accept: &lt;strong&gt;this is not a data error.&lt;/strong&gt; Query the station metadata directly and it is internally consistent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api-iwls.dfo-mpo.gc.ca/api/v1/stations?code=..."&lt;/span&gt; | jq &lt;span class="s1"&gt;'.[0] | {officialName, latitude, longitude}'&lt;/span&gt;
&lt;span class="go"&gt;{
  "officialName": "Dodd Narrows",
  "latitude": 49.134351,
  "longitude": -123.817132
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six decimals. That coordinate is not sloppy — it is precise about the wrong thing. It exists so a mariner can find "Dodd Narrows" in a list and a chart plotter can drop a pin near the pass. It was never a claim about which cross-section the predictions describe. The same is true across the region, and the giveaways are visible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Seymour Narrows   50.133333 / -125.35      ← lands on its control section (by luck)
Dodd Narrows      49.134351 / -123.817132  ← 120 m SSE of an 80 m gut
Porlier Pass      49.015    / -123.585     ← three decimals, exactly round
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Porlier's coordinate is &lt;code&gt;49.015 / -123.585&lt;/code&gt;. Three decimals, both values landing on a round thousandth. Nobody surveyed that. It is a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Blamed the section spacing
&lt;/h3&gt;

&lt;p&gt;Dodd's gut is only ~200 m long, so we cut sections at 50 m — finer than the pipeline's usual 100–200 m band. Reasonable suspicion: 50 m is under-sampled noise. So we re-ran at 100 m.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spacing   anchor area swing (shift+½ / shift−½ / datum=CD)
50 m      19.91 / 20.52 / 19.67 %
100 m     27.62 / 33.64 / 20.33 %   ← worse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worse on every axis, and the strip's narrowest section reads 100 m instead of 80 m — the coarser grid stops resolving the gut at all. Spacing was not the cause. Recorded in the pass inputs so it never gets re-suspected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Moved the anchor to the DEM throat, expecting that to be the fix
&lt;/h3&gt;

&lt;p&gt;It is the obvious next move, so we ran it as a diagnostic before touching any canonical data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anchor placed at section 20 (the 80 m gut)
  shift +½ spacing    8.60 %   ✓ inside the 10 % bar
  shift −½ spacing    9.58 %   ✓
  datum = chart datum 22.67 %  ✕ worse than before the move
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two axes fixed. The third got &lt;em&gt;worse&lt;/em&gt;. The &lt;code&gt;datum=CD&lt;/code&gt; variant recomputes every area at chart datum instead of mean water level — at Dodd that removes 3.08 m of water from the section. The gut carries about 12 m over an 80 m width, so 3.08 m is a quarter of its depth: Dodd's cross-sectional area is a strong function of tide stage in a way Seymour's 90–150 m depths simply are not (&lt;code&gt;datum=CD&lt;/code&gt; moves Seymour's anchor area by 4.56%).&lt;/p&gt;

&lt;p&gt;This is the finding that reframed the whole thing. &lt;strong&gt;The position error and the datum sensitivity are two separate problems that happened to sum to the same failing number.&lt;/strong&gt; Moving the anchor was necessary and not sufficient, and if we had only ever looked at the aggregate "19.9% vs 10%" we would have concluded the fix didn't work and reverted it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Made the area tide-stage dependent
&lt;/h3&gt;

&lt;p&gt;If area is a function of stage, stop treating stage as noise and model it: compute each section's area at seven chart-datum heights (0…6 m), evaluate the paired tide station's fitted model at runtime, interpolate the scale. The interpolation itself is fine — held-out half-metre heights come back within &lt;strong&gt;0.9164%&lt;/strong&gt;, and with the datum axis modelled rather than swept, the corrected anchor clears placement sensitivity.&lt;/p&gt;

&lt;p&gt;And Dodd still ships nothing. With a stable anchor, the surviving certified range collapses to &lt;code&gt;[20, 20]&lt;/code&gt; — a single transect, no neighbour to triangulate an interval from, &lt;strong&gt;zero cells&lt;/strong&gt; packed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tried the other bathymetry source
&lt;/h3&gt;

&lt;p&gt;The fallback grid, a CHS NONNA-10 tile, resolves the gut at 50 m. Same ending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.3× flare rule          → one transect
1.6× diagnostic widening → adjacent 80 m sections fail the unchanged 10 % bar by 12.6–32.9 %
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No bar was relaxed and no singleton transect was inflated into an area. The ruling instead is a station-local indicator centred on Dodd's corrected coordinate, drawing no fill, outline or boundary — nothing that suggests a certified spatial extent.&lt;/p&gt;

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

&lt;p&gt;The whole shipped change is one coordinate and one provenance string, in the registry that owns station identity for every consumer in the workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; chs-dodd-narrows:
   name: Dodd Narrows
   context: Nanaimo
&lt;span class="gd"&gt;-  position: [49.1344, -123.8171]
&lt;/span&gt;&lt;span class="gi"&gt;+  position: [49.13546639419797, -123.81735084108287]
&lt;/span&gt;   provider: chs
   cities: [Nanaimo]
&lt;span class="gi"&gt;+  source: GSC West Coast Topo-Bathymetric DEM v2 hydraulic control section
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus the test that pins it, because a coordinate with no test is a coordinate somebody rounds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chs-dodd-narrows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;49.13546639419797&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;123.81735084108287&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;dodd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadRegistry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chs-dodd-narrows&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dodd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GSC West Coast Topo-Bathymetric DEM v2 hydraulic control section&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that are the actual work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coordinate was recovered, not inferred.&lt;/strong&gt; The pipeline had already &lt;em&gt;recorded&lt;/em&gt; "~130 m off the control", and it would have been easy to project 130 m along the thalweg bearing and call it done. Instead: regenerate the sections from the DEM and read the generated centre of the minimum-width section straight out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./sections.py dodd-narrows
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;jq &lt;span class="s1"&gt;'.sections | to_entries | min_by(.value.width_m) | {index: .key, center: .value.center, width_m: .value.width_m}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    passes/dodd-narrows.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The offset between the two shipped positions comes out at &lt;strong&gt;120.0 m on a 351° bearing&lt;/strong&gt; — close to the recorded ~130 m, but not equal to it, because the recorded figure was section-centre to section-centre and the label is not on the centreline. Derive the number you are shipping from the artifact that produced it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full precision, no rounding.&lt;/strong&gt; &lt;code&gt;49.13546639419797&lt;/code&gt; is not false precision — it is the generated section centre, and rounding it re-introduces exactly the ambiguity the fix removes. The old value, &lt;code&gt;49.1344&lt;/code&gt;, was the published coordinate rounded to four decimals. Rounded labels are how a coordinate stops being traceable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;source:&lt;/code&gt; is the load-bearing field.&lt;/strong&gt; The registry's default provenance is documented once, repo-wide: names are hand-written, positions come from the fitting pipeline. Dodd's no longer does — it comes from a specific DEM version and a specific derivation. Without that string, in six months this is an unexplained coordinate that disagrees with the government's, and the safest-looking action is to "fix" it back.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dataset:&lt;/strong&gt; &lt;a href="https://open.canada.ca/data/en/dataset/e6e11b99-f0cc-44f7-f5eb-3b995fb1637e" rel="noopener noreferrer"&gt;Canada west coast topo-bathymetric digital elevation model&lt;/a&gt;, Geological Survey of Canada Open File 8963, 10 m grid, &lt;a href="https://open.canada.ca/en/open-government-licence-canada" rel="noopener noreferrer"&gt;OGL – Canada&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Station predictions:&lt;/strong&gt; &lt;a href="https://tides.gc.ca/en/current-predictions-station" rel="noopener noreferrer"&gt;CHS tidal current predictions&lt;/a&gt; via the IWLS API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it matters, and the traps next door
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Published reference coordinates are identifiers.&lt;/strong&gt; Every published station, gauge, buoy and sensor coordinate you consume was chosen to &lt;em&gt;name and find&lt;/em&gt; a thing. Your model may need to know where a physical process happens. Those are different requirements, and the data does not tell you which one it is satisfying. The 120 m that is a rounding error on a chart is a factor-of-two error in a cross-sectional area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rule that works at one station and not the next is not a rule you have.&lt;/strong&gt; Seymour's coordinate lands on its control section, so Seymour certified and nobody learned anything. Dodd and Porlier both do not. If your first integration succeeds, you have one sample.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detect it, don't eyeball it.&lt;/strong&gt; The single most useful line in that pipeline is the &lt;code&gt;THROAT_TOL&lt;/code&gt; branch — "the section nearest the anchor is not the narrowest in this strip." That is a cheap, mechanical assertion any geometry-anchored model can carry, and it turns a subtle physical misplacement into a printed string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the walk that keeps the anchor by construction.&lt;/strong&gt; Dodd's flare limit is &lt;code&gt;1.3 × 80 m = 104 m&lt;/code&gt;, and the surviving &lt;code&gt;kept_range&lt;/code&gt; is &lt;code&gt;[18, 18]&lt;/code&gt; — the 160 m anchor section, which &lt;em&gt;itself violates the limit&lt;/em&gt;. The bounds walk starts at the anchor and only tests neighbours, so a single-element range is never checked against its own rule. If you write an outward walk from a seed, decide deliberately whether the seed is exempt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix it in the shared registry, not the consumer.&lt;/strong&gt; The tempting patch is a local override in the pipeline that needed it. Then the app has a different Dodd from the agent tool surface, which has a different Dodd from the harmonic fitter, and the &lt;em&gt;reason&lt;/em&gt; lives in a commit message in one repo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fregistry-one-edit-three-readers.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Fcurrent-station-coordinate-is-a-label-not-the-hydraulic-control-dodd-narrows-chs-position-off-throat-continuity-cross-section-area-over-prediction-station-registry%2Fregistry-one-edit-three-readers.svg" alt="One edit to the shared station registry serves three readers: the harmonic fitter and the offline app pick it up on an npm bump, while the Python MCP server vendors a copy and needs a re-vendor that its drift test enforces." width="880" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Be honest about what "shared" buys, though. It is not instant propagation — one reader takes it on a version bump, one needs its build-time generator re-run, and one is Python with no npm at all and vendors a copy of the JSON. That last one keeps a drift test whose entire job is to fail when the copy goes stale:&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="nd"&gt;@pytest.mark.skipif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sibling station-corrections not present&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;test_bundle_matches_published_registry&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BUNDLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REAL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vendored _registry.json drifted from station-corrections/data/registry.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the registry actually buys is that the &lt;em&gt;derivation and the review happen once&lt;/em&gt;. Nobody re-derives the coordinate, nobody re-argues whether it is right, and nobody ships a second, quieter answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't let a consumer's need bend the registry's membership rule.&lt;/strong&gt; The plan for this change also wanted to pair Dodd with Nanaimo Harbour as its tide reference, because the stage-aware model needed a water-level source. Nanaimo Harbour is not a CHS-designated reference port, and that designation is the registry's entire admission rule for tide stations — an external rule, chosen precisely so a hand-curated list can't quietly grow into a mirror of the whole provider station table. So the pairing did not land. Nanaimo stayed a runtime input to the pipeline that needed it, and the registry kept its rule. A position correction is the registry's job; a private modelling dependency is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The last one is the uncomfortable one.&lt;/strong&gt; The model this coordinate was recovered for does not ship. Dodd draws no current field today, from either bathymetry source. The coordinate correction shipped anyway, and it was worth doing on its own: the pass's identity is right for every reader, anything drawn at Dodd is now drawn on the real constriction, and the next attempt at a field model starts from a good anchor and a written-down reason. &lt;strong&gt;Reference-data corrections outlive the pipelines that discover them.&lt;/strong&gt; That is a good argument for landing them separately, in their own repo, with their own tests — not as a commit inside the feature that found the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This came out of building offline current prediction for a boat that will spend a lot of time waiting on tidal gates in the Salish Sea. The station registry is public and MIT: &lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/station-corrections&lt;/code&gt;&lt;/a&gt; — names, contexts and positions for tide and current stations in both countries, shipping &lt;a href="https://github.com/sailingnaturali/station-corrections/blob/main/PROVENANCE.md" rel="noopener noreferrer"&gt;no provider-minted station id&lt;/a&gt; so it stays redistributable. The Dodd change is &lt;a href="https://github.com/sailingnaturali/station-corrections/pull/15" rel="noopener noreferrer"&gt;PR #15&lt;/a&gt;, released in v2.9.2.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-07-23-canadian-chs-tide-current-station-data-licensing-no-provider-id-runtime-name-correlation-feist-cch%20"&gt;Shipping Canadian CHS station data without redistributing the licensed file&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-08-11-tidal-gate-with-no-current-station-derived-slack-reference-tide-port-hw-lw-lag-chs-harmonic-fit-water-level-wlp-signalk-mcp%20"&gt;A tidal gate with no current station: deriving slack from a tide port&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-07-16-harmonic-tide-predictions-higher-than-tide-tables-chart-datum-vs-lat-chs-datum-offset-signalk-tides-neaps%20"&gt;Harmonic tide predictions read higher than the tide tables&lt;/a&gt;&lt;/p&gt;

</description>
      <category>currents</category>
      <category>tides</category>
      <category>marine</category>
      <category>opendata</category>
    </item>
    <item>
      <title>A localeCompare in a build script minted different public URLs per machine</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 30 Aug 2026 18:40:18 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-localecompare-in-a-build-script-minted-different-public-urls-per-machine-2ebl</link>
      <guid>https://dev.to/clarkbw--/a-localecompare-in-a-build-script-minted-different-public-urls-per-machine-2ebl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;String.prototype.localeCompare&lt;/code&gt; with no locale argument uses&lt;br&gt;
whatever collation the runtime happens to have. If a sort ordered by it&lt;br&gt;
decides an identifier — a slug, a first-wins registry key, an index — that&lt;br&gt;
identifier is pinned to the machine, not to the repo. Sort by codepoint&lt;br&gt;
(&lt;code&gt;a &amp;lt; b ? -1 : a &amp;gt; b ? 1 : 0&lt;/code&gt;) with an id tiebreak for anything a URL is&lt;br&gt;
built from, and keep &lt;code&gt;localeCompare&lt;/code&gt; for what a human reads.&lt;br&gt;
Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;Slackwater&lt;/a&gt; is an&lt;br&gt;
offline-first tide and current app for the Salish Sea. It bundles 133 NOAA&lt;br&gt;
harmonic current stations, and each one gets a human-readable URL —&lt;br&gt;
&lt;code&gt;/deception-pass&lt;/code&gt;, &lt;code&gt;/the-narrows&lt;/code&gt;, &lt;code&gt;/hale-passage&lt;/code&gt;. Those URLs get shared. They&lt;br&gt;
are the closest thing a static PWA has to a primary key.&lt;/p&gt;

&lt;p&gt;NOAA gives a lot of those stations the same name. Of the 133 bundled stations,&lt;br&gt;
&lt;strong&gt;40 share a landmark name with at least one sibling&lt;/strong&gt; — 17 names in all —&lt;br&gt;
distinguished only by a qualifier NOAA tacks on after a comma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'JSON.parse(require("fs").readFileSync("src/data/currents.json"))
&lt;/span&gt;&lt;span class="gp"&gt;    .filter(s =&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;s.name.startsWith("Hale Passage"))
&lt;/span&gt;&lt;span class="gp"&gt;    .forEach(s =&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;console.log(s.id, JSON.stringify(s.name)))'&lt;/span&gt;
&lt;span class="go"&gt;noaa/PUG1529 "Hale Passage, East end"
noaa/PUG1530 "Hale Passage, West end"
noaa/PUG1710 "Hale Passage, east of Lummi Point"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three stations, one slug: &lt;code&gt;hale-passage&lt;/code&gt;. So there's a deterministic ladder that&lt;br&gt;
hands out the collisions — plain slug, then &lt;code&gt;-current&lt;/code&gt;, then the qualifier&lt;br&gt;
folded back in, then the NOAA station id:&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;assignSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;slug&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;name&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;context&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;id&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;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&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="kr"&gt;string&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;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-current`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;toSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noaa/&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="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="s2"&gt;`&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;used&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;c&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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;slug&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;Deterministic — given an order. &lt;code&gt;assignSlug&lt;/code&gt; is called in a &lt;code&gt;.map()&lt;/code&gt; over the&lt;br&gt;
station array, so &lt;strong&gt;the first station the array reaches claims rung 1&lt;/strong&gt;. Which&lt;br&gt;
station that is depends entirely on how the array got sorted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;The array gets sorted in a build script, one line, entirely unremarkable:&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="c1"&gt;// scripts/build-currents.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stations&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="cm"&gt;/* harmonic only, primary bin, in-bounds, non-zero amplitude */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* reshape */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/data/currents.json&lt;/span&gt;&lt;span class="dl"&gt;"&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;stations&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two facts about that output file turn a sort into a URL problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: it's gitignored.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; currents.json .gitignore
&lt;span class="go"&gt;5:src/data/currents.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two: every developer-facing command rebuilds it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build:data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:stations &amp;amp;&amp;amp; npm run build:currents"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:data &amp;amp;&amp;amp; vite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:data &amp;amp;&amp;amp; tsc &amp;amp;&amp;amp; vite build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s2"&gt;"npm run build:data &amp;amp;&amp;amp; vitest run"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: whichever machine runs &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt; or &lt;code&gt;test&lt;/code&gt; regenerates the 186 KB&lt;br&gt;
station file, in that machine's sort order, and the ladder assigns public,&lt;br&gt;
permanent, already-shared URLs off the back of it. There is no tracked artifact&lt;br&gt;
anywhere in the repo recording which station got &lt;code&gt;hale-passage-current&lt;/code&gt; last&lt;br&gt;
time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Flocalecompare-not-deterministic-across-machines-icu-collation-sort-order-gitignored-generated-file-different-public-slugs-codepoint-compare%2Fslug-ladder-two-sort-orders.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Flocalecompare-not-deterministic-across-machines-icu-collation-sort-order-gitignored-generated-file-different-public-slugs-codepoint-compare%2Fslug-ladder-two-sort-orders.svg" alt="Sorting the same 133-station NOAA currents bundle by codepoint compare versus localeCompare hands the clean hale-passage-current URL to two different stations, because the slug ladder gives each rung to whichever station the sort order reaches first." width="880" height="530"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;localeCompare&lt;/code&gt; called with no locale uses the runtime's default collation, and&lt;br&gt;
collation is not codepoint order. The collision that bites this dataset is the&lt;br&gt;
simplest one there is — case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'
&lt;/span&gt;&lt;span class="gp"&gt;  const a = "Hale Passage, West end", b = "Hale Passage, east of Lummi Point";&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;  console.log("localeCompare:", a.localeCompare(b));&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="s1"&gt;// 1  -&amp;gt; West sorts AFTER east
&lt;/span&gt;&lt;span class="gp"&gt;  console.log("codepoint:    ", a &amp;lt; b);&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="s1"&gt;// true -&amp;gt; West sorts BEFORE east
&lt;/span&gt;&lt;span class="go"&gt;'
localeCompare: 1
codepoint:     true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Codepoint order puts &lt;code&gt;W&lt;/code&gt; (0x57) before &lt;code&gt;e&lt;/code&gt; (0x65). Collation folds case at the&lt;br&gt;
primary level and puts &lt;code&gt;east&lt;/code&gt; before &lt;code&gt;West&lt;/code&gt;. Both are correct sorts. Only one of&lt;br&gt;
them can be the URL.&lt;/p&gt;

&lt;p&gt;I re-ran the whole pipeline under both comparators and diffed the assigned&lt;br&gt;
slugs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node scratch/probe.mjs
&lt;span class="go"&gt;distinct base names: 110
&lt;/span&gt;&lt;span class="gp"&gt;names with &amp;gt;&lt;/span&gt;1 station: 17
&lt;span class="go"&gt;stations in a collision group: 40
FLIP noaa/PUG1530 codepoint: hale-passage-current            localeCompare: hale-passage-west-end
FLIP noaa/PUG1710 codepoint: hale-passage-east-of-lummi-point localeCompare: hale-passage-current
FLIP noaa/PUG1546 codepoint: pickering-passage-current        localeCompare: pickering-passage-west-of-squaxin-island
FLIP noaa/PUG1547 codepoint: pickering-passage-off-graham-point localeCompare: pickering-passage-current
FLIP noaa/PUG1526 codepoint: the-narrows-current              localeCompare: the-narrows-north-end-west-side
FLIP noaa/PUG1524 codepoint: the-narrows-north-end-midstream  localeCompare: the-narrows-current
stations whose public slug differs: 6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six stations, three name groups — Hale Passage, Pickering Passage, The Narrows.&lt;br&gt;
Not a hypothetical: switching the comparator immediately reassigned six live&lt;br&gt;
URLs.&lt;/p&gt;

&lt;p&gt;The severity comes from the gitignore, not from the sort. A sort that varies is&lt;br&gt;
a nuisance. A sort that varies, feeds a first-wins identifier, and writes to an&lt;br&gt;
untracked file is a class of bug where &lt;strong&gt;the wrong output can never appear in a&lt;br&gt;
diff&lt;/strong&gt;. Nobody reviews it because there is nothing to review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Flocalecompare-not-deterministic-across-machines-icu-collation-sort-order-gitignored-generated-file-different-public-slugs-codepoint-compare%2Fgitignored-artifact-no-tracked-diff.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2Flocalecompare-not-deterministic-across-machines-icu-collation-sort-order-gitignored-generated-file-different-public-slugs-codepoint-compare%2Fgitignored-artifact-no-tracked-diff.svg" alt="The build script's localeCompare sort feeds a gitignored currents.json, so the collation of whichever machine ran dev, build or test decides the permanent public slug with no tracked diff to review." width="880" height="500"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What I tried (and why it failed)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Pin the locale
&lt;/h3&gt;

&lt;p&gt;The obvious fix. &lt;code&gt;localeCompare&lt;/code&gt; is non-deterministic because the locale is&lt;br&gt;
implicit, so make it explicit:&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical output. So I widened it — every ladder outcome, under fifteen locales&lt;br&gt;
and collation options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node scratch/locales.mjs
&lt;span class="go"&gt;distinct slug outcomes across locales: 1
  group 1 en-US, en-GB, de-DE, fr-FR, sv-SE, tr-TR, cs-CZ, da-DK, ja-JP,
          zh-CN, pl-PL, es-ES, en-US-u-kf-upper, en-US-u-ka-shifted, en-US-u-kn-true
codepoint vs any-locale differing slugs: 6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every locale I could name agrees with every other locale, and all fifteen&lt;br&gt;
disagree with codepoint order. &lt;strong&gt;Pinning the locale doesn't touch the axis that&lt;br&gt;
actually flips this.&lt;/strong&gt; The &lt;code&gt;en-US-u-kf-upper&lt;/code&gt; case-first keyword doesn't do it&lt;br&gt;
either — case-first reorders &lt;code&gt;A&lt;/code&gt; against &lt;code&gt;a&lt;/code&gt; for the &lt;em&gt;same&lt;/em&gt; letter, not &lt;code&gt;W&lt;/code&gt;&lt;br&gt;
against &lt;code&gt;e&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's worth sitting with, because it's the opposite of the reassuring result.&lt;br&gt;
The variation isn't "someone in Germany gets different URLs." It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ICU presence.&lt;/strong&gt; Node's own docs mark &lt;code&gt;String.prototype.localeCompare&lt;/code&gt; as
&lt;em&gt;"partial (not locale-aware)"&lt;/em&gt; under
&lt;a href="https://nodejs.org/api/intl.html" rel="noopener noreferrer"&gt;&lt;code&gt;--with-intl=none&lt;/code&gt;&lt;/a&gt;, meaning it "carries out
its operation just like the non-&lt;code&gt;Locale&lt;/code&gt; version of the function." A Node
without full ICU produces the codepoint order in the figure above — literally
the other column. Node ships full-icu by default now, but self-built,
distro-packaged and size-trimmed container images all still exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ICU and CLDR version.&lt;/strong&gt; Collation tables ship with ICU, and ICU tracks CLDR.
This machine is &lt;code&gt;node v24.1.0 / icu 77.1 / cldr 47.0&lt;/code&gt;. A different Node major
is a different table. Punctuation and symbol weights are exactly the part of
the table that gets revised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both of those are properties of the machine. Neither is pinned by anything in&lt;br&gt;
the repo. A comparator whose answer is a property of the machine cannot be&lt;br&gt;
allowed to name a URL — and I couldn't demonstrate the flip by changing &lt;code&gt;LANG&lt;/code&gt;,&lt;br&gt;
which is precisely why it would have survived a casual look.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Just use a stable sort"
&lt;/h3&gt;

&lt;p&gt;Wrong lever, and a common reflex. &lt;code&gt;Array.prototype.sort&lt;/code&gt; has been&lt;br&gt;
&lt;a href="https://tc39.es/ecma262/#sec-array.prototype.sort" rel="noopener noreferrer"&gt;required to be stable since ES2019&lt;/a&gt;;&lt;br&gt;
V8 has been stable since Node 11. Stability guarantees that &lt;em&gt;equal&lt;/em&gt; elements keep&lt;br&gt;
their relative order. It says nothing about a comparator that decides&lt;br&gt;
inequalities differently on a different machine. A perfectly stable sort with a&lt;br&gt;
drifting comparator drifts.&lt;/p&gt;

&lt;p&gt;Stability &lt;em&gt;is&lt;/em&gt; worth a thought here, though, for the opposite reason: two&lt;br&gt;
stations could genuinely have equal names, at which point stability preserves&lt;br&gt;
whatever order the vendored extract happened to be in — which is also not&lt;br&gt;
something the repo pins. Hence the id tiebreak in the fix.&lt;/p&gt;
&lt;h3&gt;
  
  
  Commit the generated file
&lt;/h3&gt;

&lt;p&gt;Tempting: check &lt;code&gt;currents.json&lt;/code&gt; in, and a reordered build shows up as a diff.&lt;br&gt;
Real, but it's the wrong shape. It makes a wrong build &lt;em&gt;reviewable&lt;/em&gt;, not&lt;br&gt;
&lt;em&gt;impossible&lt;/em&gt; — someone still has to notice 186 KB of reordered JSON in a PR and&lt;br&gt;
work out that line 4,012 moving means a URL changed. And the file re-churns on&lt;br&gt;
every NOAA re-vendor, so the signal drowns immediately. Fix the comparator; use&lt;br&gt;
a test for the noticing.&lt;/p&gt;
&lt;h3&gt;
  
  
  The unit test that was already green
&lt;/h3&gt;

&lt;p&gt;The ladder had tests. They were added in the same session, one commit earlier,&lt;br&gt;
and they passed under &lt;strong&gt;both&lt;/strong&gt; orderings:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pins the ladder order: exact slug, then -current, then the qualifier folded in&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assignSlug&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alki Point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 mile West of&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;noaa/PUG1502&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;used&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;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assignSlug&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alki Point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;West of&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;noaa/PUG1516&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;used&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;assignSlug&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alki Point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;near the ferry dock&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;noaa/PUG1599&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point-current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Alki Point near the ferry dock&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;Read what it actually asserts. It constructs three station objects &lt;strong&gt;in an array&lt;br&gt;
literal, in the order it wants&lt;/strong&gt;, and hands them to &lt;code&gt;assignSlug&lt;/code&gt; one at a time.&lt;br&gt;
It proves the ladder walks its rungs correctly. It cannot fail because of a&lt;br&gt;
re-sort, because it never touches the sort — the third argument is even a&lt;br&gt;
freshly-constructed &lt;code&gt;Set&lt;/code&gt;, so it doesn't touch the real &lt;code&gt;used&lt;/code&gt; set either. A&lt;br&gt;
green test here is a claim about the ladder, not about the URLs the app ships.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two parts. Sort by codepoint, with the station id as the tiebreak:&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="c1"&gt;// scripts/build-currents.mjs&lt;/span&gt;
&lt;span class="c1"&gt;// Codepoint compare, not localeCompare: this file is gitignored and rebuilt on&lt;/span&gt;
&lt;span class="c1"&gt;// whatever machine runs dev/build/test, and localeCompare with no locale pinned&lt;/span&gt;
&lt;span class="c1"&gt;// uses the runtime's default collation — which can mint different public slugs&lt;/span&gt;
&lt;span class="c1"&gt;// for the colliding station names on different machines. The id tiebreak keeps&lt;/span&gt;
&lt;span class="c1"&gt;// ties (equal names) deterministic too.&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And pin the real pipeline's output — not a constructed case — for one known&lt;br&gt;
collision:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pins the real Alki Point ladder outcome (catches a re-sort silently reassigning public slugs)&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="c1"&gt;// Pinned to actual output from the build pipeline, not constructed — a&lt;/span&gt;
  &lt;span class="c1"&gt;// locale-dependent or otherwise reordered sort in build-currents.mjs would&lt;/span&gt;
  &lt;span class="c1"&gt;// flip which one gets the bare slug without any other test catching it&lt;/span&gt;
  &lt;span class="c1"&gt;// (currents.json is gitignored, so there's no tracked diff to notice either).&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oneMileWest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resolvedNoaaCurrentStations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noaa/PUG1502&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;west&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;resolvedNoaaCurrentStations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;noaa/PUG1516&lt;/span&gt;&lt;span class="dl"&gt;"&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oneMileWest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;west&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alki-point-current&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;&lt;code&gt;resolvedNoaaCurrentStations&lt;/code&gt; is the module the app itself imports, built from&lt;br&gt;
the generated JSON. &lt;code&gt;npm test&lt;/code&gt; runs &lt;code&gt;build:data&lt;/code&gt; first, so this assertion runs&lt;br&gt;
against a freshly generated bundle on every machine and in CI. That's the whole&lt;br&gt;
point: it's the only artifact in the repo that says &lt;em&gt;which station owns which&lt;br&gt;
URL&lt;/em&gt;, and it's an assertion rather than a 186 KB blob.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Split the two jobs of string comparison.&lt;/strong&gt; Collation answers "what order&lt;br&gt;
should a human read these in." Codepoint comparison answers "which of these two&lt;br&gt;
strings is canonically first." Sorting a station list for display should stay&lt;br&gt;
&lt;code&gt;localeCompare&lt;/code&gt; — an app used in Québec should file &lt;code&gt;Île&lt;/code&gt; sensibly. Anything a&lt;br&gt;
URL, cache key, content hash, or first-wins registry is built from must be&lt;br&gt;
codepoint, because the answer has to be a property of the data and not of the&lt;br&gt;
machine. &lt;code&gt;Intl.Collator&lt;/code&gt; is not an escape hatch; it's the same ICU tables with a&lt;br&gt;
nicer API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The generalization is bigger than sorting.&lt;/strong&gt; The dangerous pattern is&lt;br&gt;
&lt;em&gt;ordering that becomes an identifier&lt;/em&gt; in an &lt;em&gt;untracked&lt;/em&gt; artifact. Slugs are one&lt;br&gt;
instance. So are array-index ids in generated code, "first registration wins"&lt;br&gt;
plugin keys, and anything downstream of &lt;code&gt;Object.keys()&lt;/code&gt;, &lt;code&gt;fs.readdir()&lt;/code&gt;, or a&lt;br&gt;
glob. Ask of any generated file: if its order changed, would anything permanent&lt;br&gt;
change with it — and would a diff show me?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship the redirect, but don't count it as the fix.&lt;/strong&gt; Slackwater resolves a URL&lt;br&gt;
segment in three passes: current slug, then a recorded former slug, then the&lt;br&gt;
provider id, so an old shared link still lands on the right station. That's a&lt;br&gt;
seatbelt. It only helps for a slug someone &lt;em&gt;noticed&lt;/em&gt; had changed and wrote down.&lt;br&gt;
The bug here is specifically the class where nobody notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grep your build scripts, not just your app code.&lt;/strong&gt; &lt;code&gt;localeCompare&lt;/code&gt; in a&lt;br&gt;
component that renders a list is fine. &lt;code&gt;localeCompare&lt;/code&gt; in something that writes&lt;br&gt;
a file is a question worth answering every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rg &lt;span class="s1"&gt;'localeCompare|Intl\.Collator'&lt;/span&gt; scripts/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I run this stack on a boat — an offline-first PWA for tides and currents in the&lt;br&gt;
Salish Sea, part of the software for an all-electric charter catamaran. The&lt;br&gt;
Slackwater web app is public at&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-web" rel="noopener noreferrer"&gt;sailingnaturali/slackwater-web&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-07-18-noaa-co-ops-currents-api-harcon-empty-constituents-currbin-currents-predictions-not-available-user-agent-404%20"&gt;two false walls in NOAA's currents API&lt;/a&gt; covers where these station records come from, and &lt;a href="https://dev.to%20post_url%202026-08-11-porting-javascript-to-swift-test-oracle-neaps-tide-prediction-golden-vectors-harmonic-constituents-noaa-validation%20"&gt;porting a tide engine to Swift with the original as the test oracle&lt;/a&gt; is the same test suite from the other end — pinning real output instead of eyeballing it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>icu</category>
      <category>i18n</category>
    </item>
    <item>
      <title>plugins.allow is exclusive: the warning's suggested fix disabled 44 plugins</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 29 Aug 2026 02:48:10 +0000</pubDate>
      <link>https://dev.to/clarkbw--/pluginsallow-is-exclusive-the-warnings-suggested-fix-disabled-44-plugins-35h</link>
      <guid>https://dev.to/clarkbw--/pluginsallow-is-exclusive-the-warnings-suggested-fix-disabled-44-plugins-35h</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A config warning told me, every single turn, that &lt;code&gt;plugins.allow&lt;/code&gt; was empty — and helpfully printed the one-line fix. I pasted it onto the live box. Enabled plugins went &lt;strong&gt;47 → 3&lt;/strong&gt;, because &lt;code&gt;plugins.allow&lt;/code&gt; is an &lt;em&gt;exclusive&lt;/em&gt; allowlist over &lt;strong&gt;all&lt;/strong&gt; plugins, including the ~46 that ship bundled. The two that mattered were the fallback model provider and &lt;code&gt;bundle-mcp&lt;/code&gt;, the plugin every MCP tool hangs off. Reverted in a minute. The durable fix wasn't code — it was a comment in the config recording &lt;em&gt;why&lt;/em&gt; the warning stays unfixed. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2F2026-08-11-plugins-allow-exclusive-allowlist-config-warning-suggested-fix-breaks-production-disables-bundled-plugins-openclaw-mcp-bridge-fallback-provider%2Fenabled-plugins-before-after.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2F2026-08-11-plugins-allow-exclusive-allowlist-config-warning-suggested-fix-breaks-production-disables-bundled-plugins-openclaw-mcp-bridge-fallback-provider%2Fenabled-plugins-before-after.svg" alt="Taking the warning's suggested one-entry plugins.allow list collapsed enabled plugins from 47 to 3, and the two casualties that mattered were the fallback model provider and the MCP bridge." width="880" height="384"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The setup: &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt; running as an agent gateway on a Raspberry Pi next to a &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt; server, so I can DM a boat and ask it how much water is under the keel. I wrote up &lt;a href="https://dev.to%20post_url%202026-07-29-openclaw-raspberry-pi-signalk-boat-agent-gateway-mode-local-si-units-tools-profile-prompt-caching%20"&gt;that build&lt;/a&gt; already. This post is about one line of its log output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a warning with a fix attached
&lt;/h2&gt;

&lt;p&gt;Every turn, the gateway logged this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plugins.allow is empty; discovered non-bundled plugins may auto-load: deepseek
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string is &lt;a href="https://docs.openclaw.ai/tools/plugin" rel="noopener noreferrer"&gt;documented behaviour&lt;/a&gt;: "When &lt;code&gt;plugins.allow&lt;/code&gt; is unset and non-bundled plugins are auto-discovered from the workspace or global plugin roots, startup logs &lt;code&gt;plugins.allow is empty; discovered non-bundled plugins may auto-load: ...&lt;/code&gt;".&lt;/p&gt;

&lt;p&gt;It's a reasonable warning. Without an allowlist, a plugin that appears in a discovery root gets loaded without anyone approving it. And the fix looks trivial — there is exactly one non-bundled plugin on the box, &lt;code&gt;deepseek&lt;/code&gt;, the provider for the primary model. So: allow it, and nothing else can sneak in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"deepseek"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One key. One value. The thing the warning is asking for. I put it on the live gateway.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openclaw plugins list &lt;span class="nt"&gt;--enabled&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three. It had been 47, out of 68 discovered.&lt;/p&gt;

&lt;p&gt;The agent still answered on Telegram, cheerfully, in a way that made it look fine. Ask it for the depth and it no longer had a depth tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: "allow" is exclusive over &lt;em&gt;everything&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The mental model I had — and I suspect it's the common one — is that an allowlist governs the &lt;em&gt;untrusted&lt;/em&gt; things: the plugins that show up from outside, the ones the warning is literally about. Bundled plugins ship with the product; you don't allowlist your own dependencies.&lt;/p&gt;

&lt;p&gt;That is not what the key means. From the &lt;a href="https://docs.openclaw.ai/tools/plugin" rel="noopener noreferrer"&gt;plugin docs&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;plugins.allow&lt;/code&gt; is an exclusive allowlist. Plugin-owned tools outside the allowlist stay unavailable even when &lt;code&gt;tools.allow&lt;/code&gt; includes &lt;code&gt;"*"&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Exclusive" is the whole story, and it's exclusive over the &lt;em&gt;entire&lt;/em&gt; inventory. The docs say so in the places where it bites a specific feature rather than in the place where you'd go looking. On &lt;a href="https://docs.openclaw.ai/tools/acp-agents" rel="noopener noreferrer"&gt;ACP agents&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If &lt;code&gt;plugins.allow&lt;/code&gt; is set, it is a restrictive plugin inventory and &lt;strong&gt;must&lt;/strong&gt; include &lt;code&gt;acpx&lt;/code&gt;, or the installed ACP backend is intentionally blocked&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And in the Codex setup notes: enable the bundled &lt;code&gt;codex&lt;/code&gt; plugin first, &lt;em&gt;and&lt;/em&gt; include &lt;code&gt;codex&lt;/code&gt; in &lt;code&gt;plugins.allow&lt;/code&gt; if your config uses a restrictive allowlist. Both of those are bundled plugins. Both need to be in the list.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;"allow": ["deepseek"]&lt;/code&gt; doesn't mean "of the discovered plugins, permit deepseek." It means &lt;strong&gt;"the set of plugins on this system is: deepseek."&lt;/strong&gt; Everything else is out, including 46 things I never chose to install and never thought of as plugins at all — because they arrived as part of the product.&lt;/p&gt;

&lt;p&gt;The warning wasn't wrong. The suggestion was a correct fix for a narrower config than mine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The asymmetry that makes it survivable — and confusing
&lt;/h3&gt;

&lt;p&gt;Three plugins survived, not one. &lt;code&gt;deepseek&lt;/code&gt;, because I allowed it. And then &lt;code&gt;telegram&lt;/code&gt; and &lt;code&gt;memory-core&lt;/code&gt;, which I hadn't.&lt;/p&gt;

&lt;p&gt;They survived because of an escape hatch the docs call auto-activation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bundled opt-in plugins can auto-activate when config names one of their owned surfaces, such as a provider/model ref, channel config, CLI backend, or agent harness runtime.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My config has a &lt;code&gt;channels.telegram&lt;/code&gt; block, so the telegram plugin came back on its own. The &lt;a href="https://docs.openclaw.ai/tools/browser" rel="noopener noreferrer"&gt;browser plugin docs&lt;/a&gt; state the same rule explicitly and confirm it beats the allowlist: an explicit root &lt;code&gt;browser&lt;/code&gt; block "activates the bundled browser plugin even under a restrictive &lt;code&gt;plugins.allow&lt;/code&gt;".&lt;/p&gt;

&lt;p&gt;Now read that list of owned surfaces again — provider refs, channel config, CLI backends, agent harness runtimes. &lt;strong&gt;MCP servers are not on it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2F2026-08-11-plugins-allow-exclusive-allowlist-config-warning-suggested-fix-breaks-production-disables-bundled-plugins-openclaw-mcp-bridge-fallback-provider%2Factivation-asymmetry.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fengineering.sailingnaturali.com%2Fassets%2Fimg%2F2026-08-11-plugins-allow-exclusive-allowlist-config-warning-suggested-fix-breaks-production-disables-bundled-plugins-openclaw-mcp-bridge-fallback-provider%2Factivation-asymmetry.svg" alt="Under a restrictive allowlist, naming a chat channel in config auto-activates its plugin, but naming an MCP server does not auto-activate the MCP bridge plugin, so every MCP tool disappears." width="880" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My config names both of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"channels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"signalk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uvx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"signalk-mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming the channel rescued its plugin from the allowlist. Naming the MCP server did not rescue &lt;code&gt;bundle-mcp&lt;/code&gt; — the bundled plugin that exposes MCP server tools to the model. The MCP servers still launched. They still connected. They still passed their health probes. Their tools were simply not in the request. The &lt;a href="https://docs.openclaw.ai/tools/mcp" rel="noopener noreferrer"&gt;MCP configuration page&lt;/a&gt; never mentions that a plugin is in that path at all, which is exactly why you don't think to check it.&lt;/p&gt;

&lt;p&gt;The two lanes fail differently, and that's the trap: the lane that keeps working is the one you'd &lt;em&gt;notice&lt;/em&gt; — messages still arrive, the agent still talks. The lane that goes dark is the one you have to go looking for.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the two casualties actually cost
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;bundle-mcp&lt;/code&gt; was the visible one. The other was worse and would have been invisible for months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;anthropic-provider&lt;/code&gt;&lt;/strong&gt; — the fallback model. The gateway runs a primary provider with a second one configured behind it precisely so that a provider outage or a lapsed key &lt;em&gt;degrades&lt;/em&gt; instead of leaving the boat with no agent. With the allowlist on, the fallback plugin is not loaded. There is no fallback. Nothing tells you this; you find out the next time the primary is down, which is the worst possible moment and also the only moment it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bundle-mcp&lt;/code&gt;&lt;/strong&gt; — every tool from every MCP server. The agent goes blind to the vessel and answers from general knowledge instead, which reads as fluent and is exactly as useful as it sounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A resilience feature that silently stops being loaded is worse than never having configured it, because you stop budgeting for the failure it was supposed to absorb.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried, and why each was wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The suggested one-entry list.&lt;/strong&gt; Above. 47 → 3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "Fine — I'll list the ones I actually use."&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"deepseek"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anthropic-provider"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bundle-mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"telegram"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"memory-core"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the seductive one, and it's a trap with a delay fuse. It fixes today's breakage and leaves ~42 bundled plugins off. Some of those are load-bearing in ways that don't show up in a smoke test — they're the ones behind a feature you use twice a year. Worse, the list is now a snapshot: the next OpenClaw upgrade that adds or renames a bundled plugin ships it into a gateway that is configured to refuse it, and the failure surfaces as "that feature just doesn't work anymore" with no error and no obvious link to a config file you last touched in August.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Then enumerate all of them."&lt;/strong&gt; Read &lt;code&gt;openclaw plugins list&lt;/code&gt;, paste all ~46 bundled ids plus the one real one, done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openclaw plugins list &lt;span class="nt"&gt;--json&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].id'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;68
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct, and I'd have to re-audit that list on every upgrade forever. That's a standing maintenance tax to close a hole I hadn't yet measured. So I measured it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Actually reading what the warning protects against.&lt;/strong&gt; The risk is: a plugin lands in a discovery root and auto-loads without approval. Discovery reads configured paths, workspace roots, the global plugin root under &lt;code&gt;~/.openclaw&lt;/code&gt;, and the bundled set. On this box, the only thing that ever writes a non-bundled plugin into the global root is &lt;code&gt;openclaw plugins install&lt;/code&gt; — which is already a privileged, deliberate act by someone with a shell on the machine. Someone who can run that can also edit the allowlist. The allowlist is not a boundary against them.&lt;/p&gt;

&lt;p&gt;So the hole is real, and on this deployment it is one notch of defence-in-depth behind an act that already implies full control of the host. The cost of closing it is a hand-maintained 47-line inventory re-audited on every upgrade. That trade doesn't clear.&lt;/p&gt;

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

&lt;p&gt;Don't set the key. Write down &lt;em&gt;why&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Plugin policy. `plugins.allow` is deliberately ABSENT — do not add it.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// Every turn logs:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//   plugins.allow is empty; discovered non-bundled plugins may auto-load: &amp;lt;id&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// and suggests `"plugins": { "allow": ["&amp;lt;id&amp;gt;"] }`. That suggestion breaks&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// this gateway. Tried on the live box 2026-08-10: allow is an EXCLUSIVE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// allowlist over ALL plugins, including the ~46 bundled ones. Enabled&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// collapsed 47 -&amp;gt; 3. Survivors: the one allowed id, plus telegram and&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// memory-core, which auto-activate because config names a surface they own.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// What it costs if anyone tries it again:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//   anthropic-provider  the fallback model — a primary outage now leaves&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//                       no agent at all, and nothing warns you&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//   bundle-mcp          every MCP tool — the agent goes blind to the vessel&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// Note the asymmetry: naming `channels.telegram` auto-activates telegram,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// but naming `mcp.servers.&amp;lt;name&amp;gt;` does NOT auto-activate bundle-mcp.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;//&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// The hole the warning points at is real but small: discovery only picks up&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// non-bundled plugins from the global root, which nothing writes to except&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// `openclaw plugins install` — already a privileged act. Closing it properly&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// means listing all ~46 bundled ids and re-auditing them every upgrade.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c1"&gt;// Not worth it. Living with the warning is the considered choice.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comment is the actual deliverable. The code change was deleting three lines.&lt;/p&gt;

&lt;p&gt;And the one-command check, before and after any plugin-policy edit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;openclaw plugins list &lt;span class="nt"&gt;--enabled&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="go"&gt;47
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that number moves and you didn't intend it to, stop. It's the cheapest possible canary for a class of change whose failure mode is silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An allowlist that includes the batteries is a different feature than one that doesn't.&lt;/strong&gt; Before you set one, find out whether it governs the things that arrived &lt;em&gt;from outside&lt;/em&gt; or the things that arrived &lt;em&gt;in the box&lt;/em&gt;. "Exclusive" is doing enormous work in that sentence, and the difference between the two readings is the difference between a two-item list and a 47-item one. The fastest way to find out is to count enabled units before and after on something you can revert in a minute — not to reason about it from the key name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Suggested fixes in warnings are written for the median config.&lt;/strong&gt; A warning that prints a ready-to-paste snippet is a genuinely good affordance, and the snippet was right for a gateway whose plugin inventory is small. Mine isn't. A one-line remediation is a hypothesis about your setup, not a patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The generalizable one: record why you ignored the linter.&lt;/strong&gt; Every long-lived system accumulates warnings that are correct in the abstract and wrong to act on here. If the only artifact of "we looked into this and decided to live with it" is a chat log, then the decision has a half-life of about a month — after which someone (possibly you, possibly an agent with config-write tools) sees a helpful suggestion in a log, applies it, and re-discovers the outage from first principles. Suppressing the warning would have been worse: it hides the tradeoff instead of documenting it. A comment at the exact place someone would make the change is the cheapest durable defence there is. Mine is 30 lines guarding a key that isn't there.&lt;/p&gt;

&lt;p&gt;There's a marine version of the same lesson: a persistent alarm nobody can explain gets muted, and then the one that mattered gets muted with it — &lt;a href="https://dev.to%20post_url%202026-07-11-nmea-2000-paradox-alarm-fatigue-signalk-open-source-severity-notifications-plain-language-voice-alerts-vendor-lock-in%20"&gt;alarm fatigue by design&lt;/a&gt;. The software version is quieter and the failure is the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that should worry you
&lt;/h2&gt;

&lt;p&gt;Nothing errored. The gateway started clean, the agent replied on the channel it always replies on, and the only symptom of losing 44 plugins was an answer that didn't have any vessel data behind it. Two of the three things that survived survived &lt;em&gt;by accident&lt;/em&gt; — because their config keys happened to be on the auto-activation list.&lt;/p&gt;

&lt;p&gt;I caught it in a minute because I ran the count. If I'd just sent one test message, I'd have shipped a gateway with no model fallback and no vessel access and called the warning fixed.&lt;/p&gt;

&lt;p&gt;The gateway watches an all-electric charter catamaran that doesn't exist yet; the MCP server it went blind to is &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&gt; (MIT).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-07-29-openclaw-raspberry-pi-signalk-boat-agent-gateway-mode-local-si-units-tools-profile-prompt-caching%20"&gt;Running OpenClaw on a Raspberry Pi alongside SignalK&lt;/a&gt; — the build this config belongs to, including the &lt;code&gt;tools.profile&lt;/code&gt; gotcha that hides MCP tools a second, unrelated way; &lt;a href="https://dev.to%20post_url%202026-06-23-mcp-tools-not-showing-up-isolate-the-server-before-blaming-the-agent%20"&gt;When MCP tools break, isolate the server before blaming the agent&lt;/a&gt; — the debugging order for exactly this symptom; and &lt;a href="https://dev.to%20post_url%202026-08-02-do-i-need-an-mcp-server-vs-agents-md-curl-recipe-mcp-vs-cli-tool-schema-token-cost-breakeven%20"&gt;MCP server or a curl recipe in AGENTS.md?&lt;/a&gt; — what those MCP tools were costing and earning in the first place.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openclaw</category>
      <category>plugins</category>
      <category>config</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
