<?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>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>
    <item>
      <title>Enabling Bash costs more context than seven MCP tool schemas</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Thu, 27 Aug 2026 13:32:38 +0000</pubDate>
      <link>https://dev.to/clarkbw--/enabling-bash-costs-more-context-than-seven-mcp-tool-schemas-2h82</link>
      <guid>https://dev.to/clarkbw--/enabling-bash-costs-more-context-than-seven-mcp-tool-schemas-2h82</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Nine days ago I measured MCP tool schemas against a &lt;code&gt;curl&lt;/code&gt; recipe with a tokenizer and got a near-tie. This time I ran the agent: four arms, same model, same 12 asks, only the delivery mechanism varying. &lt;strong&gt;MCP was the cheapest arm&lt;/strong&gt; — 109,779 billed input tokens for a 12-ask conversation against 158,021 for &lt;code&gt;curl&lt;/code&gt;. The reason is not subtle: &lt;strong&gt;turning on the Bash tool costs ~2,700 tokens of harness prompt, more than all seven MCP schemas combined (1,160).&lt;/strong&gt; The thing that actually costs money is neither — it is eagerly loading eleven MCP servers into every turn. Jump to the numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&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;Part one&lt;/a&gt; counted tokens on a desk: 874 for seven &lt;code&gt;signalk-mcp&lt;/code&gt; tool schemas, 466 for the equivalent &lt;code&gt;curl&lt;/code&gt; prose in an agent file, breakeven at about one tool call per turn. Static counting is honest as far as it goes, and it left the obvious question open — &lt;em&gt;what does an agent actually spend when you hand it a shell instead of a tool?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I ran it. This is part two, and the headline number went the other way from the folk wisdom.&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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Fsession-cost-by-arm.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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Fsession-cost-by-arm.svg" alt="Billed input for one 12-ask conversation: the MCP arm costs 109,779 tokens and 0.1273 dollars, cheaper than curl-cold at 158,021 tokens, the sk CLI at 166,424 and curl-warm at 178,577." width="880" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The confound in the version of this I nearly published
&lt;/h2&gt;

&lt;p&gt;The run that started this argument had an agent reading the boat with &lt;code&gt;curl&lt;/code&gt; and looking cheap doing it. It also happened to be running with a bigger thinking budget than the MCP agent it was being compared against. Two variables, one conclusion, no experiment.&lt;/p&gt;

&lt;p&gt;So: four arms, a 2x2 over &lt;em&gt;where the domain knowledge lives&lt;/em&gt; and &lt;em&gt;how the agent reaches it&lt;/em&gt;. Model, persona, thinking budget, ask set and vessel are pinned; only the delivery mechanism moves.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;reach&lt;/th&gt;
&lt;th&gt;knowledge lives in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;signalk-mcp&lt;/code&gt;'s 7 shaped tools&lt;/td&gt;
&lt;td&gt;tested code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl-cold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bash + the server URL, nothing else&lt;/td&gt;
&lt;td&gt;nowhere — the agent discovers it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;curl-warm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bash + &lt;code&gt;curl&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the system prompt (paths + SI conversions)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bash + &lt;code&gt;sk&lt;/code&gt;, a CLI over the same &lt;code&gt;tools.py&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;tested code, loaded on call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four share one neutral persona and run flat — one agent, no subagents. That is deliberate: the production system prompt names MCP tools by hand and would have biased the Bash arms into failure.&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;PERSONA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;You are the ship&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s systems assistant aboard a sailing vessel.

Answer the crew&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s question in one or two sentences of natural spoken English.
Always give units in full — knots, metres, percent, volts, amps — never raw
symbols or abbreviations.

Use your tools to read live vessel data. Never guess or estimate a reading; if
you cannot get it, say so plainly.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;ARMS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp__signalk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;_signalk_server&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl-cold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CURL_COLD&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;Bash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl-warm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CURL_WARM&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;Bash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cli&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_CLI&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;Bash&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl-warm&lt;/code&gt; is the interesting arm — it is the &lt;a href="https://www.firecrawl.dev/blog/mcp-vs-cli" rel="noopener noreferrer"&gt;MCP-is-a-tax&lt;/a&gt; recommendation implemented properly. Everything &lt;code&gt;signalk-mcp&lt;/code&gt; encodes in Python gets moved into the prompt instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read a single path with:
  curl -s $SIGNALK_URL/signalk/v1/api/vessels/self/&amp;lt;path/with/slashes&amp;gt;

Do NOT fetch the whole vessels/self tree — it is over 16 KB. Go straight to
the path you need. The paths that matter:

  environment.depth.belowKeel / .belowTransducer / .belowSurface
  environment.wind.speedTrue / .speedApparent / .directionTrue
  electrical.batteries.house.capacity.stateOfCharge / .voltage / .current
  tanks.freshWater.0.currentLevel / tanks.blackWater.0.currentLevel
  navigation.position / .speedOverGround / .headingTrue / .state
  propulsion.port.runTime / propulsion.starboard.runTime

SignalK is strictly SI. You MUST convert before answering:
  speed m/s -&amp;gt; knots (x1.94384)   angles radians -&amp;gt; degrees (x57.2958)
  ratios 0-1 -&amp;gt; percent (x100)    temperature kelvin -&amp;gt; celsius (-273.15)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve asks, deliberately loaded with the SI traps — &lt;code&gt;7569827&lt;/code&gt; seconds of engine time, &lt;code&gt;5.506&lt;/code&gt; radians of wind direction, &lt;code&gt;0.265&lt;/code&gt; of a fresh-water tank. Scope is SignalK-only: &lt;code&gt;curl&lt;/code&gt; has no equivalent for the vault-backed servers, and benchmarking those would be a strawman.&lt;/p&gt;

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

&lt;p&gt;Three full runs, all agreeing. These are run 3, against a vessel motoring in Boundary Pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per turn — what one ask costs:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;billed input/ask&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;th&gt;output/ask&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;cost/ask&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mcp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,148&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;245&lt;/td&gt;
&lt;td&gt;9.05 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.0106&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl-cold&lt;/td&gt;
&lt;td&gt;13,168&lt;/td&gt;
&lt;td&gt;+4,020&lt;/td&gt;
&lt;td&gt;302&lt;/td&gt;
&lt;td&gt;5.54 s&lt;/td&gt;
&lt;td&gt;$0.0159&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl-warm&lt;/td&gt;
&lt;td&gt;14,881&lt;/td&gt;
&lt;td&gt;+5,733&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;6.40 s&lt;/td&gt;
&lt;td&gt;$0.0158&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cli&lt;/td&gt;
&lt;td&gt;13,869&lt;/td&gt;
&lt;td&gt;+4,720&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;242&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.79 s&lt;/td&gt;
&lt;td&gt;$0.0115&lt;/td&gt;
&lt;td&gt;12/12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Per session — what holding the whole 12-ask conversation costs:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;billed input total&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;th&gt;output total&lt;/th&gt;
&lt;th&gt;session cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;mcp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;109,779&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;2,936&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$0.1273&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl-cold&lt;/td&gt;
&lt;td&gt;158,021&lt;/td&gt;
&lt;td&gt;+48,242&lt;/td&gt;
&lt;td&gt;3,621&lt;/td&gt;
&lt;td&gt;$0.1913&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;curl-warm&lt;/td&gt;
&lt;td&gt;178,577&lt;/td&gt;
&lt;td&gt;+68,798&lt;/td&gt;
&lt;td&gt;4,847&lt;/td&gt;
&lt;td&gt;$0.1895&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cli&lt;/td&gt;
&lt;td&gt;166,424&lt;/td&gt;
&lt;td&gt;+56,645&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,902&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.1378&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both views are here because they answer different questions, and the per-ask mean flatters the expensive arms. A resident tool schema is billed on &lt;em&gt;every&lt;/em&gt; turn, so its weight grows with the conversation; dividing by ask count divides that back out. The session table is the one that matches a bill: the mcp-vs-curl-cold gap reads as 4k tokens per ask and 48k across one conversation.&lt;/p&gt;

&lt;p&gt;Correctness was a wash — &lt;strong&gt;144/144 across three runs&lt;/strong&gt;. Every arm turned 7,569,827 s into 2,102.7 hours, 5.506 rad into 315°, 8.51 m/s into 16.5 knots, and all four enumerated both standing restricted-area alarms. Sonnet does not need the conversions done for it. "Shaped tools protect correctness" did not survive contact with the data, at least for this model on non-adversarial asks.&lt;/p&gt;

&lt;p&gt;The Bash arms are consistently &lt;em&gt;faster&lt;/em&gt; (p50 5.5–6.4 s vs 9.05 s) — a real result, and the only column where the CLI story holds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raw JSON loses, and keeps losing
&lt;/h2&gt;

&lt;p&gt;A tool schema is written into the prompt once and read from cache on every subsequent turn. A &lt;code&gt;curl&lt;/code&gt; payload lands in the transcript and is re-read, uncached-then-cached, for the rest of the session. One is a fixed cost; the other is a debt that compounds.&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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Fcumulative-input-by-arm.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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Fcumulative-input-by-arm.svg" alt="Cumulative billed input diverges over a 12-ask conversation because every raw curl payload stays in the transcript, ending about 48,000 tokens above the MCP arm." width="880" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is part one's ΔR — the per-read saving — playing out over a real conversation instead of a single turn. The curl arms started ~6k behind on ask 1 and finished ~48–69k behind.&lt;/p&gt;

&lt;p&gt;Note also that &lt;code&gt;curl-warm&lt;/code&gt; cost &lt;em&gt;more&lt;/em&gt; than &lt;code&gt;curl-cold&lt;/code&gt;. Moving the domain knowledge into the prompt is not free: the cheatsheet is resident on every turn, and it bought no correctness, because there was none left to buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding that inverts the intuition
&lt;/h2&gt;

&lt;p&gt;The MCP-is-overhead argument treats tool schemas as the thing you pay for and a shell as the thing you get for free. So I measured the standing cost of each configuration directly, with an ask that calls no tool at all — everything in that number is prompt you pay for before anyone says anything.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;configuration&lt;/th&gt;
&lt;th&gt;fixed tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mcp — all 7 &lt;code&gt;signalk&lt;/code&gt; schemas&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,160&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bash tool, no guidance&lt;/td&gt;
&lt;td&gt;3,860&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bash + &lt;code&gt;sk&lt;/code&gt; cheatsheet&lt;/td&gt;
&lt;td&gt;3,943&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bash + paths + unit table&lt;/td&gt;
&lt;td&gt;4,198&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;production agent — 11 MCP servers + subagents + crew prompt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~19,800&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Enabling one Bash tool costs about 2,700 tokens more than all seven MCP schemas combined.&lt;/strong&gt; The shell is not free; it arrives with its own harness prompt describing how to use it safely, and that prompt is bigger than the schemas it was supposed to replace. Whatever a CLI saves by not being resident, it spends immediately on being allowed to run at all.&lt;/p&gt;

&lt;p&gt;Then there is the last row.&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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Ffixed-prompt-overhead.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%2Fmcp-vs-curl-cli-agent-benchmark-bash-tool-token-cost-mcp-tool-schema-overhead-eager-loading-mcp-servers-total-cost-usd-cumulative%2Ffixed-prompt-overhead.svg" alt="Fixed prompt overhead: all seven signalk MCP schemas cost 1,160 tokens while merely enabling the Bash tool costs 3,860, and a production agent with 11 eagerly loaded MCP servers carries about 19,800 tokens on every turn." width="880" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bill is the fleet, not the protocol.&lt;/strong&gt; A flat agent with one MCP server carries 1,160 tokens. The production agent daemon carries ~19,800 — 17x — because it eagerly spawns eleven MCP servers at startup and every one of them registers its schemas into every turn, whatever the question was. Ask it what time it is and you have paid for the COLREGs tool descriptions.&lt;/p&gt;

&lt;p&gt;That is an &lt;em&gt;eager loading&lt;/em&gt; problem, not an MCP problem, and the same servers cost nothing in Claude Code, which loads them lazily. Which is why the answer to "our MCP bill is too high" is almost never "rewrite the servers as CLIs" — it is "stop loading all of them on every turn."&lt;/p&gt;

&lt;p&gt;One honest wrinkle in that ~19,800: it is the median of four measurements (19,791 / 19,798 / 19,800 / 19,812). A fifth, taken cold, read 14,912 — on a cold start not every &lt;code&gt;uv&lt;/code&gt;-spawned server has registered its schemas yet, so an early measurement &lt;em&gt;understates&lt;/em&gt; the resident cost. If you go measure your own, measure it warm.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the bench got wrong, twice
&lt;/h2&gt;

&lt;p&gt;Two defects in my own harness, both of which had been quietly producing plausible numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;total_cost_usd&lt;/code&gt; is cumulative per session.&lt;/strong&gt; The SDK's &lt;code&gt;ResultMessage&lt;/code&gt; reports the cost of the session so far, not of the turn that just finished. Summing it across 12 asks sums a running total — a triangular over-count that inflates a 12-ask session by roughly 6x, and inflates it &lt;em&gt;more&lt;/em&gt; for the arms with more turns. Every cost number in an earlier draft of this post was wrong in the direction that flattered my hypothesis.&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;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_cost_usd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# WRONG: sums a running total
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The bench scored which tool got called, not what came back.&lt;/strong&gt; This is the one that stings. Scoring compared &lt;code&gt;observed_tools&lt;/code&gt; against &lt;code&gt;expected_tools&lt;/code&gt; — the agent called &lt;code&gt;battery_state&lt;/code&gt;, the corpus said it should call &lt;code&gt;battery_state&lt;/code&gt;, green. What &lt;code&gt;battery_state&lt;/code&gt; &lt;em&gt;returned&lt;/em&gt; was never examined:&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;"state_of_charge_pct"&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_v"&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_a"&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="s2"&gt;"No battery data available"&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 tool defaulted to &lt;code&gt;bank="0"&lt;/code&gt;, the SignalK convention. Our vessel publishes &lt;code&gt;electrical.batteries.house&lt;/code&gt;. So the shipped tool had been answering "no battery data" on a boat that had been publishing battery data the whole time — for months — behind a green bench.&lt;/p&gt;

&lt;p&gt;It survived that long because the corpus ask was &lt;em&gt;"How's the **house&lt;/em&gt;* battery doing?"*. The word "house" made the model pass &lt;code&gt;bank="house"&lt;/code&gt; explicitly, which worked. The test was handing the agent the answer to the question the tool was failing.&lt;/p&gt;

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

&lt;p&gt;Difference the cumulative cost, and keep both views:&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;# ResultMessage.total_cost_usd is CUMULATIVE for the session, not the turn.
&lt;/span&gt;&lt;span class="n"&gt;prev_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ask&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;asks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&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;_run_one&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;ask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd_session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd&lt;/span&gt;            &lt;span class="c1"&gt;# as reported
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd_session&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prev_cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prev_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd_session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a free consistency check hiding in keeping both: the differenced per-turn costs must sum to the final cumulative figure, and a test asserts it.&lt;/p&gt;

&lt;p&gt;For the scoring bug, the fix is to grade against the world instead of against the transcript. Every ask carries the path its answer lives at, and the runner reads that path off the server itself, next to the agent's turn:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"battery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How's the battery doing?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expected_tools"&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;"mcp__signalk__battery_state"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"truth_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"electrical.batteries.house.capacity.stateOfCharge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"truth_unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ratio-&amp;gt;percent"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fetch_truth&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;Ask&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Read the ask&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s answer straight off SignalK, next to the agent&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s turn.

    Deliberately a raw urllib GET, not signalk-mcp&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s client: the whole point is
    an independent reading the arms cannot influence.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;signalk_url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/signalk/v1/api/vessels/self/&lt;/span&gt;&lt;span class="si"&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;truth_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Independence matters more than convenience here. If the truth had been fetched through &lt;code&gt;signalk-mcp&lt;/code&gt;'s own client, the all-nulls bug would have been graded against itself and stayed green a second time.&lt;/p&gt;

&lt;p&gt;And the ask lost a word: &lt;em&gt;"How's the battery doing?"&lt;/em&gt;. The corpus should never contain the hint that papers over the defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth carrying away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A benchmark that scores tool identity is a routing test, not a correctness test.&lt;/strong&gt; Both are worth having; only one of them notices that the tool returned nulls. If your evals assert "the model called the right function", assume you have a class of bug that is structurally invisible to them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the vessel, not the corpus.&lt;/strong&gt; The golden asks had drifted from where the boat was. Editing the expected answers would have been quicker and would have silently broken comparability with every earlier run; moving the vessel back kept a year of results comparable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session totals, not per-ask means.&lt;/strong&gt; Anything resident is billed per turn, so per-ask averaging systematically flatters whichever arm has the fattest resident prompt. Report the number that looks like an invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small models may still want shaped tools.&lt;/strong&gt; This is Sonnet, n=12, one vessel. A separate n=10 probe against a local &lt;code&gt;qwen3.6&lt;/code&gt; suggests the picture changes below the frontier: raw value retrieval was correct in every run — it can fetch a number — but &lt;em&gt;interpreting&lt;/em&gt; one, "is the battery charging or discharging?", scored &lt;strong&gt;3/10 with thinking off&lt;/strong&gt; (one run degenerated into &lt;code&gt;...&lt;/code&gt;) and &lt;strong&gt;10/10 with thinking on low&lt;/strong&gt;. Retrieval is not the hard part; the semantics on top of it are, and that is exactly where a tool returning &lt;code&gt;"discharging"&lt;/code&gt; instead of &lt;code&gt;-3.1&lt;/code&gt; earns its keep. Re-measure before betting an on-vessel local model on these results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The vessel is ashore.&lt;/strong&gt; Phase 0: the SignalK server runs a mock vessel plugin. Token counts, response shapes and defects are real; the readings are synthetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it lands
&lt;/h2&gt;

&lt;p&gt;I published the argument that MCP is overhead, then built the experiment to prove it, and the experiment said the opposite. MCP was the cheapest arm on both views in all three runs, and the honest version of the CLI case is much narrower than the one I was making: &lt;code&gt;sk&lt;/code&gt; earns its place for SSH sessions, cron, headless &lt;code&gt;claude -p&lt;/code&gt; and non-MCP agents — reach an MCP server cannot provide. It is just not a token optimization, which is what I had proposed it as.&lt;/p&gt;

&lt;p&gt;The money was never in the protocol. It is in eleven servers loading eagerly, 19,800 tokens deep, on a turn that only needed to know the time.&lt;/p&gt;

&lt;p&gt;Both halves are public: &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&gt; is the server, and the four-arm harness lives in &lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;&lt;code&gt;naturali-agents&lt;/code&gt;&lt;/a&gt; under &lt;code&gt;poseidon/bench/&lt;/code&gt; — part of the agent stack I'm building for an all-electric charter catamaran that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &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? Measure the breakeven&lt;/a&gt; — part one, the static token count and the keel-offset sign that decided it; &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;Benchmark your own LLM workload before migrating&lt;/a&gt; — the same harness pointed at models instead of tool surfaces; 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; — where the curl recipe came from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>Drop the vendor station ID: licensing as a data-model constraint</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:44:44 +0000</pubDate>
      <link>https://dev.to/clarkbw--/drop-the-vendor-station-id-licensing-as-a-data-model-constraint-34gg</link>
      <guid>https://dev.to/clarkbw--/drop-the-vendor-station-id-licensing-as-a-data-model-constraint-34gg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &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; 2.0.0 deleted &lt;code&gt;providerId&lt;/code&gt;, the one field in a public MIT registry that pointed &lt;em&gt;into&lt;/em&gt; a provider's database. Consumers now join records by station &lt;strong&gt;name&lt;/strong&gt; and resolve the provider's opaque handle at runtime, under their own licence to that API. It cost a major version and reshaped four repos — and it's a stronger control than a compliance note in a README, because the id can't leak from a field that doesn't exist. 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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fcommitted-ids-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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fcommitted-ids-before-after.svg" alt="The same nineteen CHS station handles were committed three times across three published packages, fifty-seven copies in all, and station-corrections 2.0.0 took every one of them to zero." width="880" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Don't redistribute the vendor's identifiers" is normally a compliance checkbox — a line in a NOTICE file, a paragraph in a licence audit, nothing that touches code. This one turned into an architecture. Here's what it actually cost, and what it bought.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;station-corrections&lt;/code&gt; is a small public npm package that publishes &lt;em&gt;station identity&lt;/em&gt; for tide and current stations in the Salish Sea: the friendly name, the corrected position, search aliases, and a stable key that URLs and caches key on. Every record looked like this:&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-dodd-narrows&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;Dodd Narrows&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;Nanaimo&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;49.1344&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-123.8171&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;providerId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;63aef1866a2b9417c035030f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package ships a &lt;code&gt;PROVENANCE.md&lt;/code&gt; that walks the schema field by field and says where each value came from, because the whole claim of the package is &lt;em&gt;these are our own facts, not a copy of anyone's station export&lt;/em&gt;. Names are hand-written. Contexts and aliases don't exist in provider data at all. Positions come out of a harmonic-fitting pipeline and get audited against a bundled coastline by a person.&lt;/p&gt;

&lt;p&gt;And then there was one row that read differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| &lt;span class="sb"&gt;`providerId`&lt;/span&gt;, &lt;span class="sb"&gt;`providerBin`&lt;/span&gt; | &lt;span class="gs"&gt;**The provider's own opaque handle**&lt;/span&gt;, necessarily obtained from the
  provider because it keys the provider's API (&lt;span class="sb"&gt;`63aef09f…`&lt;/span&gt; for CHS, &lt;span class="sb"&gt;`PUG1717`&lt;/span&gt; for NOAA). It is a
  reference/pointer — the minimum needed to be interoperable — and a fact. This is the one field
  that points &lt;span class="ge"&gt;*into*&lt;/span&gt; a provider's system, by construction. |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The contributor guide made the exception explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; The &lt;span class="sb"&gt;`providerId`&lt;/span&gt; is the exception — it is the provider's handle and there is nowhere else
  to get it. That is expected and fine.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Canadian data behind those hex strings comes from the Canadian Hydrographic Service's IWLS API, whose &lt;a href="https://tides.gc.ca/en/licence-agreement" rel="noopener noreferrer"&gt;licence agreement&lt;/a&gt; is a bespoke Crown licence, not the Open Government Licence. Clause 3 is one sentence long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The copyrights of CHS in the Data remain the property of CHS and shall not be
sold, licensed, leased, assigned or given to a third party.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An MIT npm package is, structurally, giving something to third parties. Nineteen CHS station handles were sitting in it, then re-published in a SignalK plugin's &lt;code&gt;defaults.ts&lt;/code&gt;, then re-published again in a Python MCP server's vendored copy of the registry — fifty-seven committed copies of the same nineteen handles, across three packages anyone can &lt;code&gt;npm install&lt;/code&gt; or &lt;code&gt;pip install&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;Two things were true at once, and only noticing both makes the fix obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: an id is the weakest thing you can defend as "just a fact."&lt;/strong&gt; The provenance doc leans on &lt;em&gt;Feist&lt;/em&gt; (US) and &lt;em&gt;CCH&lt;/em&gt; (Canada) — facts aren't copyrightable, and neither jurisdiction has an EU-style database right. That argument is strong for a position (independently derivable: chart, gazetteer, direct observation) and strong for a name (hand-written editorial work). It's weakest exactly where the contributor guide admitted the weakness: &lt;strong&gt;there is nowhere else to get it.&lt;/strong&gt; A field whose only possible provenance is the vendor's database is the field where "we obtained this independently" stops being available as a defence. The sentence written to justify the exception is a precise statement of why the exception is the problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the field was doing two jobs.&lt;/strong&gt; It was the &lt;em&gt;identity&lt;/em&gt; of a station — what caches, resource keys and cross-package joins keyed on — and it was the &lt;em&gt;fetch handle&lt;/em&gt; the provider's API wants in a URL path. Those are different lifetimes. Identity should be stable forever and safe in a URL. A fetch handle is whatever the vendor's index says today. Conflating them is what made the id feel load-bearing and undeletable.&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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fid-path-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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fid-path-before-after.svg" alt="Before, the provider-minted station id was copied into the registry and republished under MIT to every consumer; after, the registry publishes only name, position and a stable key, and each consumer resolves the live id from the provider API at runtime under its own licence." width="880" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Separate the two jobs and the licence problem dissolves: the identity half is our own fact and publishable, the fetch-handle half never needs to be written down.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Attempt 1: document the exception harder.&lt;/strong&gt; The first response was the provenance table above — a careful, honest, field-by-field account ending in "carried because interoperability requires them." It's good documentation and it moves zero bytes. The published tarball is identical before and after you write it. A README does not change what a package distributes, and clause 3 is about what you distribute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: just delete the field.&lt;/strong&gt; This is where it stops being a paperwork exercise. The plugin's entire online path is keyed on that 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;defaultFetcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DayFetcher&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;s&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;s&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="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchChsEvents&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="nx"&gt;stationId&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="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fetchNoaaEvents&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="nx"&gt;stationId&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="nx"&gt;noaaBin&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete &lt;code&gt;providerId&lt;/code&gt; and there is no &lt;code&gt;stationId&lt;/code&gt; to fetch with — you don't get a compile error in the registry package, you get a plugin that can no longer answer "when is slack at Dodd Narrows?" at all, online or off. The design spec had to spell out the three separate things a gate needs at runtime before anything could be removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Need                      | Old source (deleted)   | New source
--------------------------|------------------------|----------------------------------------
name / position / label   | defaults.ts            | station-corrections (by name/key)
live CHS id (online fetch)| defaults.ts stationId  | resolved live from IWLS /stations by name
offline constituents      | did not exist          | operator-triggered local build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sibling fitting library had the same coupling, expressed as a guard that &lt;em&gt;required&lt;/em&gt; the id to be present:&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;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;key&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;name&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&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;providerId&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;`registry entry &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;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; has an empty key, name, or providerId`&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="na"&gt;id&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;providerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&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;name&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Attempt 3: keep the id, but only where it's public domain.&lt;/strong&gt; NOAA data &lt;em&gt;is&lt;/em&gt; US-government public domain — &lt;code&gt;PUG1717&lt;/code&gt; is genuinely free to redistribute. So carve out CHS and keep the field for NOAA rows. This was rejected, and the reason is worth stating: a schema field whose legality depends on which row you're looking at is a field every future contributor has to reason about correctly, forever, in a package that takes drive-by PRs from anyone who spots a wrong station name. &lt;code&gt;PUG1717&lt;/code&gt; and its depth-cell bin came out too. The rule in the shared package is uniform — &lt;em&gt;no provider-minted identifier, ever&lt;/em&gt; — and the one consumer that genuinely may ship a NOAA id keeps it in its own package, where the provider is fixed and the reasoning is local.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 4: assume the two provider endpoints share an id namespace.&lt;/strong&gt; The plugin lists stations from &lt;code&gt;api-iwls.dfo-mpo.gc.ca&lt;/code&gt; but fetches events from &lt;code&gt;api-sine.dfo-mpo.gc.ca&lt;/code&gt;. If those two mint different ids, "resolve from IWLS, fetch from SINE" returns an empty array for every gate — no error, no 404, just currents quietly gone. That is not a thing to find out after wiring four repos together, so it got a throwaway probe first:&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;stations&lt;/span&gt; &lt;span class="o"&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="nf"&gt;fetch&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;IWLS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/stations`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dodd&lt;/span&gt; &lt;span class="o"&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;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;officialName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Dodd Narrows&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SINE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/stations/&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;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/data?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&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;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;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PASS: shared id namespace&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;FAIL: ids differ — move event fetch to IWLS&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;Twenty-three lines, one request, PASS. Had it failed, the live event fetch would have moved to IWLS as well — a different plan entirely, and much cheaper to discover before the refactor than during it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. The registry ships identity and nothing else.&lt;/strong&gt; Two &lt;code&gt;feat!&lt;/code&gt; commits and a major version:&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-dodd-narrows&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;Dodd Narrows&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;Nanaimo&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;49.1344&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;-123.8171&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;provider&lt;/code&gt; stays — "this is a CHS gate" is a fact about the world and tells a consumer which API to go ask. The validator drops &lt;code&gt;providerId&lt;/code&gt; from its required-fields list and deletes the &lt;code&gt;providerBin&lt;/code&gt; range check entirely. &lt;code&gt;PROVENANCE.md&lt;/code&gt;'s exception row became a prohibition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| provider id | &lt;span class="gs"&gt;**Deliberately absent.**&lt;/span&gt; The registry carries no provider-minted identifier at
  all — not even as a reference. A consumer joins a record here to a provider's live data by
  name; the provider's own opaque handle is resolved at runtime by whoever holds a licence to
  that provider's API, and it never enters this repository. |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Consumers split identity from the fetch handle.&lt;/strong&gt; In the SignalK plugin, &lt;code&gt;stationId&lt;/code&gt; becomes the stable registry key and a new &lt;code&gt;liveId&lt;/code&gt; carries the ephemeral 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="c1"&gt;// Ephemeral IWLS station id for a CHS gate, resolved live at runtime (see&lt;/span&gt;
&lt;span class="c1"&gt;// resolveLiveIds). NEVER committed. `stationId` above is the stable identity&lt;/span&gt;
&lt;span class="c1"&gt;// (registry key for CHS); `liveId` is only the fetch handle.&lt;/span&gt;
&lt;span class="nx"&gt;liveId&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;/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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chsLiveId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StationConfig&lt;/span&gt;&lt;span class="p"&gt;):&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="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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveId&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;`no live id for &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="nx"&gt;label&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="k"&gt;return&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;liveId&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 throw is the enforcement. There is no path where a CHS fetch silently falls back to something committed, because there is nothing committed to fall back to.&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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fidentity-split.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%2Fdrop-vendor-station-id-chs-iwls-crown-copyright-licence-registry-providerid-resolve-station-id-by-name-signalk-currents%2Fidentity-split.svg" alt="One station now carries two identifiers: a stable committed key that every cache, URL and resource is keyed on, and an ephemeral provider handle resolved at startup and used only to make the fetch." width="880" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Resolve the handle at runtime, from the provider, by name.&lt;/strong&gt; The whole resolver is twenty-odd lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;currentStations&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="nx"&gt;RawStation&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;IwlsStation&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;raw&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;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="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="nx"&gt;timeSeries&lt;/span&gt; &lt;span class="o"&gt;??&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;t&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wcsp1&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;officialName&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="nx"&gt;longitude&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;officialName&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="nx"&gt;longitude&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveLiveIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;resp&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;fetchFn&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;IWLS_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/stations`&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;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;`IWLS &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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;stations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;currentStations&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;RawStation&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;new&lt;/span&gt; &lt;span class="nc"&gt;Map&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;map&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="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;normalizeName&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="nx"&gt;officialName&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs at &lt;code&gt;start()&lt;/code&gt;, holds the map in memory for the life of the process, and tolerates failure by falling through to the offline path. The id never reaches a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. One normalizer, applied symmetrically, in every language that joins.&lt;/strong&gt; TypeScript:&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;// Same folding rule chs-constituents uses, so "JUAN DE FUCA - EAST" matches the&lt;/span&gt;
&lt;span class="c1"&gt;// registry's "Juan de Fuca - East".&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;normalizeName&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&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;toLowerCase&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;Python, in the MCP server that reads the plugin's &lt;code&gt;/currents&lt;/code&gt; resource and joins each reading back to a registry gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Join key for correlating a gate to a plugin reading: fold case and trim
    so a label/name that differs only in casing or spacing still matches.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied on &lt;strong&gt;both&lt;/strong&gt; sides — once when the cache is built, once on lookup — so a whitespace difference between the plugin's label and the registry's name can't silently drop a station.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Tests that keep it true rather than a rule that asks nicely.&lt;/strong&gt; The fitting library's overlay test asserts the absence directly:&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;keys entries by normalized name and reads no id at all&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;// No providerId field anywhere — proves the overlay is forward-compatible&lt;/span&gt;
  &lt;span class="c1"&gt;// with the registry dropping providerId in Phase 2.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overlay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;registryOverlay&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;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="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;Dodd Narrows&lt;/span&gt;&lt;span class="dl"&gt;"&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="s2"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;overlay&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;dodd narrows&lt;/span&gt;&lt;span class="dl"&gt;"&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="na"&gt;key&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="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Dodd Narrows&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;and the MCP server's vendored copy of the registry is guarded by a drift test that fails the moment it stops byte-matching the published one.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A licence term makes a better schema constraint than a policy.&lt;/strong&gt; Compare "reviewers should check that nobody adds a provider id" against "the schema has no such field and the validator rejects unknown ones." The second one survives a contributor who never read &lt;code&gt;PROVENANCE.md&lt;/code&gt;, which is most of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name joins are fragile exactly where ids are robust — so put the renames somewhere.&lt;/strong&gt; An opaque id survives a rename; a name join doesn't. The mitigation is that the registry is the thing that &lt;em&gt;owns&lt;/em&gt; renaming: change a station's display name there and every downstream package follows, because they all read it. What the join can't survive is the &lt;strong&gt;provider&lt;/strong&gt; renaming its station, and that is a real, unhandled failure mode — the gate goes offline-only until someone updates the registry name. The fitting library degrades gracefully (an unmatched live station keeps its official name and gets no key); the plugin logs and falls back. Neither pretends it matched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shipping the change broke the boat, twice, and both were migration bugs — not the design.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Nineteen duplicated gates.&lt;/em&gt; An operator config written before 2.0.0 still names a gate by its provider-minted UUID. That never collides with the registry's slug, so both entries survived the merge, and the stale one resolved no live id either:&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;// Re-key such an entry onto the registry slug so it lands as the OVERRIDE it was&lt;/span&gt;
&lt;span class="c1"&gt;// always meant to be: the slug is what resolveLiveIds works from, and every other&lt;/span&gt;
&lt;span class="c1"&gt;// field the operator set (set directions, estimate flags, bins) rides along.&lt;/span&gt;
&lt;span class="c1"&gt;// Deliberately keyed on label, not position: renaming a station is the registry's&lt;/span&gt;
&lt;span class="c1"&gt;// job and downstream is supposed to follow it.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was invisible for weeks because downstream consumers key on the label and silently keep whichever copy they saw last. When you re-key a shared identifier, write the migration for configs you don't control — someone is running the old shape.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ten stations throwing every poll cycle.&lt;/em&gt; The registry later grew tide reference ports alongside current gates. Tide ports publish no &lt;code&gt;wcsp1&lt;/code&gt; series, so &lt;code&gt;resolveLiveIds&lt;/code&gt; finds nothing for them and the fetch guard fired ten times a cycle:&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 Point Atkinson
no live id for Campbell River
…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix wasn't a filter in the plugin — that filter existed and was already wrong. Selection moved into the package that owns the data, as an exported &lt;code&gt;currentGates()&lt;/code&gt;. If a package can grow a new row type, only that package can be trusted to say which rows are which.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scrub the whole repo, not just &lt;code&gt;src/&lt;/code&gt;.&lt;/strong&gt; After the code was clean, a CHS id was still sitting in a fenced code sample inside an old implementation-plan doc, where no grep of the source would find it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  { provider: 'chs', stationId: '63aef1866a2b9417c035030f', label: 'Dodd Narrows', … },
&lt;/span&gt;&lt;span class="gi"&gt;+  { provider: 'chs', stationId: '&amp;lt;24-hex-id&amp;gt;', label: 'Dodd Narrows', … },
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs, specs, plans, test fixtures, issue templates, commit messages. Grep for the shape of the thing, not the name of the field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the invariant structural where you can.&lt;/strong&gt; The offline harmonic bundle a user builds locally is written to the server's data directory — outside the repo and outside the npm package — so it &lt;em&gt;cannot&lt;/em&gt; be committed or published by accident. That's not discipline, it's geography, and geography doesn't get tired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reward is the part nobody asks for.&lt;/strong&gt; Because nothing downstream stores a provider handle any more, renaming a station is now a one-line PR in one repo, and the version of the package that made this legally safe is the same version that made renames free. That was not the goal; it fell out of deleting the field that pointed sideways into someone else's database.&lt;/p&gt;




&lt;p&gt;Built while putting a boat-agent stack together for an all-electric charter catamaran, where "the Canadian rapids stop working offline" is a licensing problem before it is a software one. The registry is &lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;&lt;code&gt;station-corrections&lt;/code&gt;&lt;/a&gt;; the plugin is &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt;&lt;/a&gt;; the fitting pipeline that has to be run rather than downloaded is &lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;&lt;code&gt;chs-constituents&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-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; — the same licence wall, one layer down, where it decides which predictions you can bundle at all. 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;the NOAA CO-OPS currents API gotchas&lt;/a&gt; for the other half of this cruising ground, where none of this applies.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>currents</category>
      <category>licensing</category>
    </item>
    <item>
      <title>The A/B that measured nothing: three ways my agent experiment was invalid</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 25 Aug 2026 20:16:09 +0000</pubDate>
      <link>https://dev.to/clarkbw--/the-ab-that-measured-nothing-three-ways-my-agent-experiment-was-invalid-5bl7</link>
      <guid>https://dev.to/clarkbw--/the-ab-that-measured-nothing-three-ways-my-agent-experiment-was-invalid-5bl7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I set out to test whether an MCP tool should return a rendered sentence or raw SI numbers. The experiment was invalid three times, for three unrelated reasons: the tools had never been called (a gateway tool-policy gap), the two arms were byte-identical at the model (the gateway drops a tool's text &lt;code&gt;content&lt;/code&gt; when &lt;code&gt;structuredContent&lt;/code&gt; is present), and once it genuinely measured, the new shape &lt;em&gt;lost&lt;/em&gt;. What actually fixed the behaviour was deleting a stale paragraph in the workspace instructions that told the model to do the opposite. 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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fadherence-by-arm.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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fadherence-by-arm.svg" alt="Verbatim adherence to the MCP tool's rendered display string went from an unmeasurable zero, through 1 of 5 for the sentence-first payload and 3 of 5 for the plain JSON dump, to 6 of 6 once a contradicting instruction was removed from the agent's workspace file." width="880" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two months ago I published a post here with a confident thesis:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Move the formatting into the tool response.&lt;/strong&gt; […] a prompt rule isn't a fix, it's a suggestion the model is free to ignore the moment the raw value is still in front of it. You can't reliably instruct the model &lt;em&gt;not&lt;/em&gt; to reformat data it can plainly read. The only reliable lever is the data itself.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://dev.to%20post_url%202026-06-05-fix-llm-formatting-in-the-tool-layer-not-the-prompt%20"&gt;Fix LLM formatting in the tool layer, not the prompt&lt;/a&gt;, 2026-06-05&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I still think that's mostly right. But when I finally tried to &lt;em&gt;measure&lt;/em&gt; it — on a second agent runtime, with a different model — the experiment fell over three times before it produced a single valid number, and when it did produce one, the number pointed the other way. This is the write-up of the three invalidities, because each one is a trap you can hit on your own stack, and the second one is a genuine gotcha for anyone writing MCP servers.&lt;/p&gt;

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

&lt;p&gt;The stack: a set of MCP servers over a boat's &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt; data, driving a voice-and-chat agent. Following the June post, every tool returns a pre-rendered &lt;code&gt;display&lt;/code&gt; string next to the raw SI fields:&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;"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;"house"&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="mf"&gt;0.65&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="mf"&gt;12.39&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="mf"&gt;-2.95&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="s2"&gt;"65 percent, 12.4 volts, 3.0 amps discharging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-10T04:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent's persona file carries a verbatim carve-out — quote the &lt;code&gt;display&lt;/code&gt; string, don't re-render from the raw fields. Asked "depth and house battery?", the agent answered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;65% SOC, 12.39 V, -2.95 A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raw SI, units abbreviated the way a datasheet does and a person doesn't, and a negative amperage — which the persona explicitly forbids, because "minus two point nine five amps" is not how you tell someone the battery is discharging. One run even helpfully &lt;em&gt;explained&lt;/em&gt; the minus sign.&lt;/p&gt;

&lt;p&gt;So: &lt;code&gt;display&lt;/code&gt; exists, the rule exists, and the model ignores both. Reproduced on two models — a small fast open-weights one and a large cloud one. Asked to quote the rules back, both reproduced them verbatim, then broke them in the next sentence.&lt;/p&gt;

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

&lt;p&gt;My hypothesis, straight out of the June post: &lt;code&gt;display&lt;/code&gt; is losing an attention contest. It sits &lt;em&gt;below&lt;/em&gt; the raw fields in the JSON dump, so a model reading top-down meets &lt;code&gt;soc_fraction&lt;/code&gt; first, decides it knows what a 0–1 fraction is, and renders from that. Fix the shape — put the sentence first, or in its own content block — and the failure mode disappears.&lt;/p&gt;

&lt;p&gt;That hypothesis was testable. What I didn't check was whether anything in the chain between the tool and the model would let me test it. Three things wouldn't:&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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fwhere-the-ab-broke.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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fwhere-the-ab-broke.svg" alt="Three separate breaks between an MCP tool and the model: the gateway tool policy filtered the MCP tools out entirely, the gateway result formatter dropped the tool's text content in favour of structuredContent, and the workspace instructions file told the model to convert SI units, contradicting the persona rule that said to quote the rendered string." width="880" height="530"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Attempt 1 — enforce it harder in the prompt
&lt;/h3&gt;

&lt;p&gt;The first move was the one the June post says not to make, on the theory that this particular rule is about &lt;em&gt;quoting a string I already hold&lt;/em&gt;, which that post explicitly carves out as a legitimate prompt instruction. So I ported the carve-out into the gateway's persona file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When a tool returns a `display` string, quote it verbatim.
Never re-render the reading from the raw SI fields beside it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deployed, loaded, in context — the agent could recite it. It then ignored it, on both models, across two wordings.&lt;/p&gt;

&lt;p&gt;Conclusion at the time: prompt-level enforcement of this carve-out does not hold, therefore the structural fix in the tool layer is required. &lt;strong&gt;That conclusion was worthless.&lt;/strong&gt; Hold that thought.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2 — return the sentence as &lt;code&gt;content&lt;/code&gt;, the numbers as &lt;code&gt;structuredContent&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;MCP lets a tool return both an unstructured &lt;code&gt;content&lt;/code&gt; array and a structured &lt;code&gt;structuredContent&lt;/code&gt; object. That looked perfect: make the text content &lt;em&gt;be&lt;/em&gt; the answer, and let the numbers ride in the structured half where threshold reasoning can still reach them deliberately.&lt;/p&gt;

&lt;p&gt;In the Python MCP SDK that's a tuple return:&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;@server.call_tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="c1"&gt;# PILOT, depth_state only. `display` sits BELOW the raw SI fields in the
&lt;/span&gt;    &lt;span class="c1"&gt;# JSON dump, so a model reading top-down meets `below_keel_m` first and
&lt;/span&gt;    &lt;span class="c1"&gt;# re-renders from it. Make the rendered sentence the answer instead, and
&lt;/span&gt;    &lt;span class="c1"&gt;# put the numbers in structuredContent — still there for threshold
&lt;/span&gt;    &lt;span class="c1"&gt;# reasoning, but one deliberate reach away.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depth_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;result&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&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="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])],&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deliberately one tool only. The other six kept the old shape, so a single "depth and battery" ask A/B'd both shapes against each other in one reply, on one model, in one turn. Clean design. Five trials per arm. The new shape looked better.&lt;/p&gt;

&lt;p&gt;It was measuring nothing. &lt;strong&gt;The gateway renders &lt;code&gt;structuredContent&lt;/code&gt; and discards the tool's text &lt;code&gt;content&lt;/code&gt; blocks.&lt;/strong&gt; The sentence never reached the model at all; what arrived was the same JSON object the old shape sent, so both arms were byte-identical from the model's side.&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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fidentical-arms.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%2Fmcp-structuredcontent-discards-text-content-agent-gateway-invisible-mcp-tools-invalid-ab-test-agents-md-contradiction%2Fidentical-arms.svg" alt="The MCP tool returned a rendered sentence as a text content block alongside structuredContent, but the gateway forwarded only the structuredContent, so the model received the same JSON as the arm the pilot was supposed to beat." width="880" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one is worth naming precisely, because it will bite other MCP authors. The gateway here is &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt;, and as of &lt;strong&gt;v2026.7.1-2&lt;/strong&gt; the behaviour is in &lt;a href="https://github.com/openclaw/openclaw/blob/a89b88ec0ea756c48e2d28f246ac0f325e420c51/src/agents/agent-bundle-mcp-materialize.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/agents/agent-bundle-mcp-materialize.ts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;structuredContentBlock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&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="na"&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;text&lt;/span&gt;&lt;span class="dl"&gt;"&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="s2"&gt;`structuredContent:\n&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Structured results replace mirrored text, but original non-text blocks&lt;/span&gt;
  &lt;span class="c1"&gt;// still carry images, linked resources, and audio that the JSON cannot mirror.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalizedContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AgentToolResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;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;content&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;structuredContentBlock&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nx"&gt;structuredContentBlock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;sourceContent&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;block&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;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&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="nx"&gt;mcpContentBlockToAgentContent&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;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;content&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;filter((block) =&amp;gt; block.type !== "text")&lt;/code&gt; is the whole story: when &lt;code&gt;structuredContent&lt;/code&gt; is present, every text block is dropped. Images, resource links and audio survive — that carve-out was &lt;a href="https://github.com/openclaw/openclaw/pull/115521" rel="noopener noreferrer"&gt;added in July 2026&lt;/a&gt;. Text does not.&lt;/p&gt;

&lt;p&gt;Two caveats before you copy this into your own mental model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version-pin it.&lt;/strong&gt; This code path has flipped twice in ninety days. Before &lt;a href="https://github.com/openclaw/openclaw/pull/87540" rel="noopener noreferrer"&gt;#87540&lt;/a&gt; (merged 2026-05-28) the precedence was the &lt;em&gt;other&lt;/em&gt; way round — &lt;code&gt;content&lt;/code&gt; won and &lt;code&gt;structuredContent&lt;/code&gt; was dropped, which was &lt;a href="https://github.com/openclaw/openclaw/issues/87511" rel="noopener noreferrer"&gt;filed as a bug&lt;/a&gt;. Don't assume either behaviour without checking the version you're actually running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a defensible reading of the spec&lt;/strong&gt;, which is what makes it dangerous. The MCP specification says:&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content" rel="noopener noreferrer"&gt;MCP specification 2025-06-18, Tools&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The spec's own example returns &lt;code&gt;content: [{"type": "text", "text": "{\"temperature\": 22.5, …}"}]&lt;/code&gt; beside &lt;code&gt;structuredContent: {"temperature": 22.5, …}&lt;/code&gt; — the same data twice. So a client that has parsed &lt;code&gt;structuredContent&lt;/code&gt; is entitled to treat the text block as a redundant mirror and drop it. My pilot did the one thing the spec doesn't anticipate: it put &lt;em&gt;different&lt;/em&gt; information in the two halves. &lt;strong&gt;If your &lt;code&gt;content&lt;/code&gt; is not a serialization of your &lt;code&gt;structuredContent&lt;/code&gt;, you are outside what the spec promises, and at least one popular client will silently throw it away.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3 — sentence first, numbers after, in one text block
&lt;/h3&gt;

&lt;p&gt;Ordering inside a single text block is the only lever that survives every client, so that's what the pilot became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depth_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;result&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&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="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which produces a payload that is unambiguously different from the control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.7 metres under the keel

{
  "below_keel_m": 3.7,
  "depth_m": 5.2,
  ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the numbers still didn't move, because of the third invalidity — the one that had also voided attempt 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  The invalidity underneath the other two: the tools had never been called
&lt;/h3&gt;

&lt;p&gt;Seven MCP tools, all probing healthy, none of them ever invoked. The gateway's tool policy admitted a base profile plus an explicit extra list — and the extra list didn't include the group that MCP server tools live 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;"tools"&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;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"minimal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"alsoAllow"&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;"group:runtime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group:fs"&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;In OpenClaw, &lt;a href="https://docs.openclaw.ai/gateway/config-tools" rel="noopener noreferrer"&gt;&lt;code&gt;tools.profile&lt;/code&gt;&lt;/a&gt; "sets a base allowlist before &lt;code&gt;tools.allow&lt;/code&gt;/&lt;code&gt;tools.deny&lt;/code&gt;", and &lt;code&gt;minimal&lt;/code&gt; allows &lt;code&gt;session_status&lt;/code&gt; only. Configured MCP servers are "exposed as plugin-owned tools under the &lt;code&gt;bundle-mcp&lt;/code&gt; plugin id", and the docs note that the broader profiles — "&lt;code&gt;coding&lt;/code&gt; and &lt;code&gt;messaging&lt;/code&gt; also implicitly allow &lt;code&gt;bundle-mcp&lt;/code&gt;" — do that implicitly. &lt;code&gt;minimal&lt;/code&gt; does not. The docs even describe the exact symptom, under sandboxing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If &lt;code&gt;mcp.servers&lt;/code&gt; is configured but sandboxed turns only show built-in tools, add &lt;code&gt;bundle-mcp&lt;/code&gt;, &lt;code&gt;group:plugins&lt;/code&gt;, or a server-prefixed MCP tool name/glob […] to &lt;code&gt;tools.sandbox.tools.alsoAllow&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The failure mode is nasty because &lt;strong&gt;nothing errors&lt;/strong&gt;. The server connects, &lt;code&gt;tools/list&lt;/code&gt; succeeds, every health probe is green, and the tools are filtered out before the request ever reaches the model. From the outside it looks identical to a working install. And the agent still answered questions about the boat perfectly well — because the workspace instructions predated the MCP servers and told it to shell out to &lt;code&gt;curl&lt;/code&gt; against the HTTP API, which it dutifully did.&lt;/p&gt;

&lt;p&gt;So no &lt;code&gt;display&lt;/code&gt; string had ever reached a model. Attempt 1 measured a rule against a payload that didn't exist. Attempt 2's arms were identical for a &lt;em&gt;second&lt;/em&gt;, independent reason on top of the &lt;code&gt;structuredContent&lt;/code&gt; one.&lt;/p&gt;

&lt;p&gt;The one-line fix is adding the group to the allow list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&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;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"minimal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"alsoAllow"&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;"group:runtime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group:fs"&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="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;Worth noting on its own: the agent switched from &lt;code&gt;curl&lt;/code&gt; to &lt;code&gt;signalk__depth_state&lt;/code&gt; / &lt;code&gt;signalk__battery_state&lt;/code&gt; &lt;strong&gt;immediately, with no change to the instructions telling it to use &lt;code&gt;curl&lt;/code&gt;&lt;/strong&gt;. Tool availability beat documented instruction. The prose was never what held the MCP path back.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then the honest measurement said I was wrong
&lt;/h3&gt;

&lt;p&gt;With the arm genuinely connected, one question exercising both shapes at once, five trials per arm, on the small fast model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;arm&lt;/th&gt;
&lt;th&gt;quoted &lt;code&gt;display&lt;/code&gt; verbatim&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;battery_state&lt;/code&gt; — plain JSON dump, &lt;code&gt;display&lt;/code&gt; buried mid-object&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 / 5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;depth_state&lt;/code&gt; — rendered sentence first, numbers after&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 / 5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Leading with the sentence didn't help. If anything it hurt. Small n, one model, so I won't claim the effect is real in the other direction — but there is no evidence for the hypothesis, and the pilot was carrying a special case in a dispatch function, so it went:&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;git revert &lt;span class="nt"&gt;--no-edit&lt;/span&gt; 4c43abd
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;span class="go"&gt;89d3b40 Revert the depth_state output-shape pilot: measured, did not help
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The lever was neither the prompt nor the payload. It was a &lt;strong&gt;contradiction between them&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The workspace instructions file — written when the agent read the boat over &lt;code&gt;curl&lt;/code&gt;, and never revisited after the MCP servers went in — contained this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Units are SI. Convert for the user:
  m/s → kn   × 1.94384
  rad → deg  × 57.2958
  K   → °C   − 273.15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an explicit, specific, actionable order to re-render raw numbers — which is exactly what the persona's carve-out forbids. The model wasn't being non-compliant. It was obeying the more specific of two conflicting instructions. Rewriting that section to point at the tools instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Read the vessel through the &lt;span class="sb"&gt;`signalk__*`&lt;/span&gt; tools, not &lt;span class="sb"&gt;`curl`&lt;/span&gt;.
Each returns a &lt;span class="sb"&gt;`display`&lt;/span&gt; string that is already rendered for a
human. Quote it. Do not re-render the reading from the raw SI
fields beside it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three trials, both tools: &lt;strong&gt;6 / 6 verbatim&lt;/strong&gt;. No change to the tool layer at all.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The generalizable lesson is the failure mode, not the fix:&lt;/strong&gt; when an agent ignores a rule, check what &lt;em&gt;else&lt;/em&gt; in its context tells it to do the opposite before concluding the model is non-compliant. An agent's context is assembled from a persona file, a workspace file, tool descriptions, and tool results, all written at different times by people optimizing for different things. "The model won't follow instructions" is very often "the model is following the other instruction."&lt;/p&gt;

&lt;p&gt;That complicates the June post rather than overturning it. Formatting still belongs in the tool response — that's what makes &lt;code&gt;display&lt;/code&gt; deterministic and available. But the tool layer is not sufficient on its own, because it competes with a prompt surface that can contradict it, and it is not &lt;em&gt;causally&lt;/em&gt; the lever when a contradiction exists. &lt;strong&gt;Fix the contradiction first, then optimize the payload — and check that the payload is actually arriving.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The traps nearby, in the order they'll bite you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A healthy tool is not a called tool.&lt;/strong&gt; Green probes, successful &lt;code&gt;tools/list&lt;/code&gt;, and a working agent are all compatible with the model never having seen your tools. Before benchmarking anything about tool &lt;em&gt;output&lt;/em&gt;, prove a tool was &lt;em&gt;invoked&lt;/em&gt; — read the session transcript for the call, don't infer it from a plausible answer. (Ours answered correctly the whole time, via &lt;code&gt;curl&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put novel information in &lt;code&gt;content&lt;/code&gt; when you also send &lt;code&gt;structuredContent&lt;/code&gt;.&lt;/strong&gt; The spec frames &lt;code&gt;content&lt;/code&gt; as a backwards-compatibility mirror, and clients act accordingly. If you need the model to read a rendered string, put it in the one payload every client forwards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B'ing two arms of a payload requires proving the arms differ at the model,&lt;/strong&gt; not at the server. Log or dump what the client actually forwarded. Mine was a byte-for-byte match across a difference I'd written 40 lines of code to create.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every "the model won't obey" conclusion is provisional until the plumbing is proven.&lt;/strong&gt; I wrote a struck-through wrong conclusion into my own docs, twice, on the strength of experiments that hadn't measured anything.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This is one thread out of building an AI ops layer for an all-electric charter catamaran — MCP servers over SignalK, a self-hosted agent gateway on a Raspberry Pi, and a voice front-end where "what's the battery doing" has to come back as something a human can hear. The server the code above is from is open source: &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;signalk-mcp&lt;/a&gt;, including &lt;a href="https://github.com/sailingnaturali/signalk-mcp/commit/89d3b406255aaddedf3e7d51d47b0ad025267f41" rel="noopener noreferrer"&gt;the revert&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-05-fix-llm-formatting-in-the-tool-layer-not-the-prompt%20"&gt;Fix LLM formatting in the tool layer, not the prompt&lt;/a&gt; · &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; · &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; · &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? Measure the breakeven&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
