<?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>Two alert lanes: the siren shouldn't depend on an LLM</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 21 Aug 2026 16:05:14 +0000</pubDate>
      <link>https://dev.to/clarkbw--/two-alert-lanes-the-siren-shouldnt-depend-on-an-llm-4hep</link>
      <guid>https://dev.to/clarkbw--/two-alert-lanes-the-siren-shouldnt-depend-on-an-llm-4hep</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Don't put a model in the path of the alarm that matters most. Split the notification router in two: &lt;code&gt;alarm&lt;/code&gt;/&lt;code&gt;emergency&lt;/code&gt; go &lt;strong&gt;straight to the Telegram Bot API&lt;/strong&gt; from the SignalK plugin — no gateway, no model, no billing relationship — while &lt;code&gt;alert&lt;/code&gt;/&lt;code&gt;warn&lt;/code&gt; wake an agent turn that reads the vessel and writes one considered message. The lane that matters most then depends on the fewest components. 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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Ffailure-modes.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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Ffailure-modes.svg" alt="A failure-mode grid showing the hard lane still fires when the agent gateway is wedged, the model API is down, the gateway token is broken or the billing has lapsed, while the soft lane is silent in every one of those cases." width="880" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In June I wrote up the first generation of this: &lt;a href="https://dev.to%20post_url%202026-06-05-signalk-ntfy-push-notifications-to-phone-zero-dependency-relay%20"&gt;a zero-dependency SignalK relay&lt;/a&gt; that turned a notification into a phone push in about a second. This is the second generation, and it exists because that relay came out of the stack and I briefly replaced a one-second push with a twelve-hour one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem: the alarm path became a cron job
&lt;/h2&gt;

&lt;p&gt;The relay's push service went away, and what filled the gap was an agent gateway running a scheduled boat check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 7,19 * * *    boat-watch    # wake the agent, ask it to look at the vessel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twice a day. A condition arising at 02:00 waits until 07:00. &lt;strong&gt;Worst-case alarm latency went from about a second to about twelve hours.&lt;/strong&gt; That is survivable only because my boat is ashore and the vessel data is mocked; it is not survivable with real sensors.&lt;/p&gt;

&lt;p&gt;The naive fix is obvious and wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;* * * * *    boat-watch    # every minute — problem solved?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency fixed, shape still wrong. Every notification now goes through a model turn, so a dragging anchor waits on an LLM round trip — and inherits every way that round trip can fail. The alarm that matters most ends up gated behind the slowest, most expensive, least reliable component in the stack, and the failure is silent: an agent that never wakes looks exactly like a quiet boat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: count the things that have to work
&lt;/h2&gt;

&lt;p&gt;My first instinct was "fine, send hard alarms through the gateway, just faster." That turns out not to be possible, and the reason it isn't is what produced the design.&lt;/p&gt;

&lt;p&gt;The gateway's inbound hook surface exposes exactly two actions — &lt;code&gt;wake&lt;/code&gt; and &lt;code&gt;agent&lt;/code&gt; — and &lt;strong&gt;both of them run a model turn&lt;/strong&gt;. There is no no-model message-send endpoint over HTTP. There is a CLI that can send a plain message, but it's a client over the gateway's WebSocket RPC, not something a plugin can call.&lt;/p&gt;

&lt;p&gt;So "route the siren through the gateway for tidiness" means the siren depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SignalK  →  router  →  internet  →  gateway process  →  hook token
                                 →  model API  →  provider account in good standing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six things, two of which are somebody else's business relationship with me. Whereas going straight to the messaging platform's Bot API depends on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SignalK  →  router  →  internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things, all of which have to be up anyway for a notification to exist and leave the boat. Once you write both chains down, the split writes itself.&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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Ftwo-lanes.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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Ftwo-lanes.svg" alt="The router subscribes to the SignalK notifications tree and splits it three ways: every forwardable row to MQTT, alarm and emergency straight to the Telegram Bot API with no model in the path, and alert and warn through an agent gateway and a model API." width="880" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A hard alarm takes &lt;strong&gt;both&lt;/strong&gt; lanes: the siren immediately, and a follow-up agent turn that adds context when — if — it's ready. The siren has to stand alone, because on a bad day it's the only message that arrives.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. A path allowlist to decide what pages
&lt;/h3&gt;

&lt;p&gt;The first routing rule I wrote was a list of paths worth waking someone for:&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;// don't do this&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUSH_PATHS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/^navigation&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;anchor$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^electrical&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;batteries&lt;/span&gt;&lt;span class="se"&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;voltage$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/^mob$/&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;shouldPush&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;PUSH_PATHS&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;re&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;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fails on contact with real data. The whale-protection zone plugin I run raises zone presence as a &lt;code&gt;warn&lt;/code&gt;, and those zones blanket most of the waterways here — so "inside a restricted area" is the &lt;em&gt;normal&lt;/em&gt; condition, not an exception. Every SignalK restart re-fires them. Under a severity-only rule that's a page on every restart; under an allowlist it's fine right up until a new plugin ships an alarm you forgot to add, and then it's silent. An allowlist for alarms is a denylist you maintain by getting paged.&lt;/p&gt;

&lt;p&gt;The data already had the answer in it:&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;"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;"notifications.navigation.restrictedArea.&amp;lt;zone-id&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&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;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"method"&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;"visual"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Inside 400m Approach Distance Prohibition"&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;&lt;code&gt;method: ["visual"]&lt;/code&gt; is the publisher saying &lt;em&gt;display this, do not sound it&lt;/em&gt;. Meanwhile every distress plugin I run sets &lt;code&gt;method: ["visual", "sound"]&lt;/code&gt;. The publishers had been declaring their intent the whole time and my router was ignoring it in favour of a regex list I'd have to keep current forever.&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;&lt;code&gt;method&lt;/code&gt; decides whether a notification pushes at all; &lt;code&gt;state&lt;/code&gt; only picks the lane.&lt;/strong&gt; No allowlist anywhere. If something routes wrong, it gets fixed at the publisher, which is where the knowledge actually lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A coalesce window re-armed on every arriving row
&lt;/h3&gt;

&lt;p&gt;The soft lane batches — one SignalK restart can transition six paths at once, and six concurrent agent turns is both a spam burst and a bill. My first window was the obvious one:&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;// don't do this either&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onSoftRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flushTimer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// "reset the window"&lt;/span&gt;
  &lt;span class="nx"&gt;flushTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flush&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;em&gt;sliding&lt;/em&gt; window, and a sliding window is a promise that a steady trickle never gets delivered. Rows arriving every 4 seconds reset a 10-second timer forever; the batch grows and never flushes. Which is a strange thing to build into the path of "something is wrong on the boat."&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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Fcoalesce-timer.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%2Fsignalk-notification-router-two-alert-lanes-hard-soft-telegram-bot-api-no-llm-in-the-alarm-path-method-array-sound-coalesce-window%2Fcoalesce-timer.svg" alt="A timeline comparison: re-arming the window on every arriving row means a steady trickle resets it forever and the batch never flushes, while arming the timer once from the oldest pending row flushes on schedule at ten seconds as one agent turn." width="880" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The predecessor to this plugin had a &lt;code&gt;coalesce(transitions, window_s)&lt;/code&gt; function with five tests asserting that the window runs from the oldest pending row. None of them got ported, because the timer &lt;em&gt;is&lt;/em&gt; the rule once you arm it in the right place:&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;// The window runs from the OLDEST pending row: arm once when the buffer goes&lt;/span&gt;
&lt;span class="c1"&gt;// non-empty, never re-arm while one is in flight.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;armFlush&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;flushTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coalesceSeconds&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;flushTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flushSoft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flushTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;flushTimer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;if (flushTimer) return;&lt;/code&gt; is the entire fix, and it makes the property structural rather than asserted.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Posting &lt;code&gt;{"message": "..."}&lt;/code&gt; to the agent hook
&lt;/h3&gt;

&lt;p&gt;The soft lane looked healthy for weeks. The hook returned &lt;code&gt;200&lt;/code&gt;, the gateway logged an agent turn, the turn ran and read the vessel. Nobody ever got a message.&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;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOOK_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HOOK_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H 'Content-Type: application/json' \
    -d '{"message":"tank level rising"}'
{"ok":true,"runId":"..."}

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;...and &lt;span class="k"&gt;in &lt;/span&gt;the gateway&lt;span class="s1"&gt;'s run log:
&lt;/span&gt;&lt;span class="gp"&gt;Delivering to Telegram requires target &amp;lt;chatId&amp;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 gateway takes the delivery target as a &lt;strong&gt;body field&lt;/strong&gt;, not something implied by the hook URL. Both the old Python sidecar and the plugin's first cut posted only &lt;code&gt;message&lt;/code&gt;, so every soft alarm since the sidecar era had completed an agent turn and then dropped the answer on the floor. A &lt;code&gt;200&lt;/code&gt; from a hook endpoint means &lt;em&gt;admitted&lt;/em&gt;, not &lt;em&gt;delivered&lt;/em&gt;, and I had been reading it as the latter.&lt;/p&gt;

&lt;p&gt;The fix stays gateway-agnostic — the plugin merges an opaque JSON blob the operator supplies, and never learns anyone's field names:&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;"hookBodyExtra"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;deliver&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:true,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;channel&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;telegram&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;to&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;123456789&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;body&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="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;parseHookExtra&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hookBodyExtra&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;message&lt;/code&gt; is spread last, so operator JSON can never clobber it.&lt;/p&gt;

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

&lt;p&gt;Two lanes, one classifier, and the classifier is nine lines:&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;HARD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alarm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emergency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// siren + agent follow-up&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SOFT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;        &lt;span class="c1"&gt;// agent turn only&lt;/span&gt;

&lt;span class="c1"&gt;// Which push lane this notification takes, or null for no push.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;method&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;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;INACTIVE&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;state&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;SEVERITY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&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;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sound&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HARD&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;state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hard&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;SOFT&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;state&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;soft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routing is then a shape, not a policy engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;route&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hardEnvs&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;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&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;lane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;lane&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;hardEnvs&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;buildEnvelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;position&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
    &lt;span class="k"&gt;else&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;lane&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;soft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;pendingSoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Hard lane fires per-row immediately and never coalesces — deduplication is&lt;/span&gt;
  &lt;span class="c1"&gt;// a comfort feature, a missed alarm is not.&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;env&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hardEnvs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telegram&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="nf"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;renderSiren&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;opts&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;env&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hardEnvs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;deliver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hook&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="nf"&gt;postHook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;renderFollowupPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;opts&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;pendingSoft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;armFlush&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 hard sender is a bare &lt;code&gt;fetch&lt;/code&gt; to the platform's Bot API. No SDK, no gateway, nothing between the plugin and the internet:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.telegram.org/bot&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telegramBotToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sendMessage`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telegramChatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;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;`telegram sendMessage failed: HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that function are load-bearing. &lt;strong&gt;The bot token is in the request URL&lt;/strong&gt;, so the error path checks &lt;code&gt;res.status&lt;/code&gt; by hand and never surfaces &lt;code&gt;res.url&lt;/code&gt; or a raw transport error — a rejected fetch whose message carried the URL would write the token verbatim into the server log. And &lt;code&gt;deliver()&lt;/code&gt; is fire-and-forget rather than awaited, so a wedged gateway can't serialize a later row's siren behind an earlier row's hook timeout.&lt;/p&gt;

&lt;p&gt;Verified the way you'd want it verified — stop the gateway, inject a real distress notification, watch what happens:&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;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; stop &amp;lt;agent-gateway&amp;gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node scripts/inject-test-notification.js &lt;span class="nt"&gt;--path&lt;/span&gt; test.hardLane &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --state alarm --method visual,sound --message "TEST - ignore"

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SignalK log:
&lt;span class="go"&gt;delivery error on the hook lane: fetch failed
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Telegram: the siren arrived anyway.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;The routing rule binds every path that can reach a human, not just the router.&lt;/strong&gt; The plugin is not the only thing that reads the notifications tree — there's also a once-daily dead man's switch, an independent agent run whose whole job is to notice that an event-driven router has gone quiet (a router that has stopped emitting is indistinguishable from a calm boat). It reads SignalK on its own, so it needed its own copy of the &lt;code&gt;method&lt;/code&gt; rule. It didn't have one, and on its first real scheduled fire it happily paged me about the standing zone &lt;code&gt;warn&lt;/code&gt; that the router had been correctly ignoring. The rule now lives in three implementations that must stay in step, and "anything new that reads the notifications tree and can reach a human gets the rule before it ships" is a written policy rather than a thing I remember.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A correct config file is not evidence a lane is live.&lt;/strong&gt; I spent an evening debugging a soft lane whose on-disk config was perfect — URL, token, body extras all set — and which routed nothing and logged nothing. The plugin instance was still holding pre-save config; the last relevant line in the server log was from before the edit. The only positive evidence a lane will deliver is the startup line:&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;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;currentOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telegramBotToken&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;currentOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telegramChatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no Telegram bot token/chat id — the hard lane (siren) will NOT deliver&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;currentOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hookUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;currentOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hookToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no agent hook URL/token — the soft lane will NOT deliver&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;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;docker logs signalk | &lt;span class="nb"&gt;grep &lt;/span&gt;notification-router
&lt;span class="go"&gt;signalk-notification-router: no agent hook URL/token — the soft lane will NOT deliver
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line, after the restart that followed the config save, is the check. Nothing else is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;method&lt;/code&gt; gates before severity, and it gates silently.&lt;/strong&gt; A test notification at &lt;code&gt;alert&lt;/code&gt; with &lt;code&gt;method: ["visual"]&lt;/code&gt; is dropped by &lt;code&gt;classify&lt;/code&gt; on purpose, with no log line at all — correct behaviour that is completely indistinguishable from a broken lane while you're testing one. Test with an injector that sets &lt;code&gt;method&lt;/code&gt; explicitly:&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;SIGNALK_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... node scripts/inject-test-notification.js &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--path&lt;/span&gt; test.softLane &lt;span class="nt"&gt;--state&lt;/span&gt; warn &lt;span class="nt"&gt;--method&lt;/span&gt; visual,sound &lt;span class="nt"&gt;--message&lt;/span&gt; &lt;span class="s2"&gt;"TEST - ignore"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(A &lt;code&gt;PUT&lt;/code&gt; to the notifications REST path returns &lt;code&gt;404&lt;/code&gt; — no handler is registered. The delta WebSocket is the only way in.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lane split survived the rewrite, which is the real test of it.&lt;/strong&gt; All of the above started life as a Python sidecar polling SignalK's REST API every 5 seconds, walking the notification tree, and hand-rolling edge detection. Moving it into an in-process SignalK plugin deleted the poll timer, the tree walk, the edge-trigger bookkeeping, a container, and a &lt;code&gt;network_mode: host&lt;/code&gt; workaround — the delta subscription gives you all of it. The one property I refused to let dissolve in the port was the hard lane's short dependency chain. In-process is fine (if SignalK is down there are no notifications to route), but the siren still goes straight out to the Bot API rather than being routed through the agent for tidiness.&lt;/p&gt;

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

&lt;p&gt;This is the notification plumbing for an all-electric charter catamaran, where "the anchor is dragging" has to reach a phone whether or not a language model is having a good night. The plugin is MIT and on npm as &lt;a href="https://www.npmjs.com/package/@sailingnaturali/signalk-notification-router" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/signalk-notification-router&lt;/code&gt;&lt;/a&gt; — source on &lt;a href="https://github.com/sailingnaturali/signalk-notification-router" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, including the &lt;code&gt;classify&lt;/code&gt; and &lt;code&gt;armFlush&lt;/code&gt; code above and the tests around them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-06-05-signalk-ntfy-push-notifications-to-phone-zero-dependency-relay%20"&gt;Push SignalK alarms to your phone with a zero-dependency relay&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-07-11-nmea-2000-paradox-alarm-fatigue-signalk-open-source-severity-notifications-plain-language-voice-alerts-vendor-lock-in%20"&gt;The NMEA 2000 paradox has an open-source answer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>notifications</category>
      <category>alarms</category>
      <category>ai</category>
    </item>
    <item>
      <title>SignalK's two token classes: why plugin routes 401 your device token</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:05:06 +0000</pubDate>
      <link>https://dev.to/clarkbw--/signalks-two-token-classes-why-plugin-routes-401-your-device-token-2hh4</link>
      <guid>https://dev.to/clarkbw--/signalks-two-token-classes-why-plugin-routes-401-your-device-token-2hh4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — SignalK server has two token classes with different powers. A &lt;strong&gt;device token&lt;/strong&gt; (Security → Access Requests) can write data — deltas, PUTs — but gets &lt;code&gt;401 Permission Denied&lt;/code&gt; on every &lt;code&gt;/plugins/&amp;lt;id&amp;gt;/*&lt;/code&gt; and &lt;code&gt;/skServer/*&lt;/code&gt; route, because those are admin-gated and a device token is &lt;code&gt;readwrite&lt;/code&gt;, not &lt;code&gt;admin&lt;/code&gt;. Plugin REST APIs need a &lt;strong&gt;user JWT&lt;/strong&gt; from &lt;code&gt;POST /signalk/v1/auth/login&lt;/code&gt; — and that JWT silently expires per the &lt;code&gt;expiration&lt;/code&gt; field in &lt;code&gt;security.json&lt;/code&gt; (the field the admin UI calls "Remember Me timeout"; the code falls back to &lt;code&gt;1h&lt;/code&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%2Fsignalk-401-permission-denied-plugin-routes-device-token-vs-user-jwt-security-json-expiration%2Ftoken-class-vs-route-class.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%2Fsignalk-401-permission-denied-plugin-routes-device-token-vs-user-jwt-security-json-expiration%2Ftoken-class-vs-route-class.svg" alt="A SignalK device token passes the readwrite gate on data paths but returns 401 on every /skServer and plugin route, where only an admin user JWT gets through." width="880" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Our agent writes log entries through the &lt;a href="https://github.com/meri-imperiumi/signalk-logbook" rel="noopener noreferrer"&gt;signalk-logbook&lt;/a&gt; plugin's REST API. One day we went looking for a specific entry and found the log had a week-long hole in it. No crash, no alert, nothing in the dashboards — every &lt;em&gt;read&lt;/em&gt; of the system looked perfectly healthy. The writes had been dying with a 401 the entire time, and the client's fire-and-forget POST swallowed every one.&lt;/p&gt;

&lt;p&gt;The 401 turned out to be two different auth problems stacked on top of each other, and both come from the same under-documented fact: &lt;strong&gt;a SignalK server's auth surface differs per route class, and the two kinds of token it mints are not interchangeable.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The setup is ordinary: a SignalK server in Docker with security enabled and &lt;code&gt;allow_readonly&lt;/code&gt; on, and an automation client that reads vessel data anonymously and writes log entries with a bearer token.&lt;/p&gt;

&lt;p&gt;Reads: fine, no token needed —&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; http://localhost:3000/signalk/v1/api/vessels/self/navigation/position
&lt;span class="go"&gt;{"meta":{...},"value":{"longitude":...,"latitude":...},...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writes to the plugin's REST API with the token we had: not fine —&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="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H 'Content-Type: application/json' \
    -d '{"text":"Test entry"}' \
    http://localhost:3000/plugins/signalk-logbook/logs
&lt;/span&gt;&lt;span class="gp"&gt;You do not have permission to view this resource, &amp;lt;a href='/admin/#&lt;/span&gt;/login&lt;span class="s1"&gt;'&amp;gt;Please Login&amp;lt;/a&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With an &lt;code&gt;Accept: application/json&lt;/code&gt; header the same rejection comes back as:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Permission Denied"&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;And because the client treated the POST as fire-and-forget, that response went nowhere. The read path being anonymous is exactly what made this invisible: everything you'd casually check — positions, dashboards, the admin UI — kept working, because none of it exercises the write path or the token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: two token classes
&lt;/h2&gt;

&lt;p&gt;SignalK mints two different kinds of bearer token, and they pass different gates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device tokens&lt;/strong&gt; come from the access-request flow (&lt;a href="https://signalk.org/specification/1.7.0/doc/access_requests.html" rel="noopener noreferrer"&gt;spec&lt;/a&gt;): the client POSTs to &lt;code&gt;/signalk/v1/access/requests&lt;/code&gt;, the admin approves it under &lt;strong&gt;Security → Access Requests&lt;/strong&gt; and picks a permission level. The JWT carries a &lt;code&gt;device&lt;/code&gt; claim, and the principal it resolves to has the granted permission — typically &lt;code&gt;readwrite&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User JWTs&lt;/strong&gt; come from logging in as a user account:&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="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -d '{"username":"agent","password":"..."}' \
    http://localhost:3000/signalk/v1/auth/login
{"timeToLive":3600,"token":"eyJhbGciOiJIUzI1NiIs..."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JWT carries an &lt;code&gt;id&lt;/code&gt; claim, and the principal resolves to the user's type — which can be &lt;code&gt;admin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why it matters: in &lt;a href="https://github.com/SignalK/signalk-server/blob/master/src/tokensecurity.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/tokensecurity.ts&lt;/code&gt;&lt;/a&gt;, ordinary data writes only require &lt;code&gt;readwrite&lt;/code&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;// writeAuthenticationMiddleware — deltas, PUTs&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;skReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skPrincipal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="nx"&gt;skReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skPrincipal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;readwrite&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="nf"&gt;next&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;…but every plugin route and a list of server routes are mounted behind the &lt;strong&gt;admin&lt;/strong&gt; middleware:&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/restart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/runDiscovery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/plugins&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/appstore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/backup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/restore&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/providers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/vessel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/serialports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&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;SERVERROUTESPREFIX&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;adminAuthenticationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/plugins&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;adminAuthenticationMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SERVERROUTESPREFIX&lt;/code&gt; is &lt;code&gt;/skServer&lt;/code&gt;, and anything a plugin registers with &lt;code&gt;registerWithRouter&lt;/code&gt; is mounted under &lt;code&gt;/plugins/&amp;lt;plugin.id&amp;gt;&lt;/code&gt; — so the entire REST API of every plugin sits behind that last line. &lt;code&gt;adminAuthenticationMiddleware&lt;/code&gt; accepts exactly one permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasAdminAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;skReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skIsAuthenticated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;skReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skPrincipal&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;skReq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skPrincipal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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;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%2Fsignalk-401-permission-denied-plugin-routes-device-token-vs-user-jwt-security-json-expiration%2Fwhere-the-401-is-raised.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%2Fsignalk-401-permission-denied-plugin-routes-device-token-vs-user-jwt-security-json-expiration%2Fwhere-the-401-is-raised.svg" alt="The same bearer token takes two paths through a SignalK server: data paths clear the readwrite gate and return 200, while /skServer and plugin routes hit the admin middleware, which raises the 401 before the plugin's own handler ever runs." width="880" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So a &lt;code&gt;readwrite&lt;/code&gt; device token is simultaneously &lt;strong&gt;good enough to write vessel data and permanently insufficient for any plugin's HTTP API&lt;/strong&gt;. Same server, same &lt;code&gt;Authorization&lt;/code&gt; header, different gate per route class. (You &lt;em&gt;can&lt;/em&gt; approve a device as Admin in current server versions, but the UI defaults to Read Only and the natural choice for a data-writing device is Read/Write — which is how you end up here.)&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Attempt 1 — re-mint the device token.&lt;/strong&gt; First theory: the token expired. New access request, approved with permission Read/Write and Token Expiry NEVER. Data writes confirmed working — a course-API PUT (gated on &lt;code&gt;readwrite&lt;/code&gt;, like deltas) goes through fine:&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="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$DEVICE_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H 'Content-Type: application/json' \
    -d '{"position": {"latitude": 48.86, "longitude": -123.35}}' \
    http://localhost:3000/signalk/v2/api/vessels/self/navigation/course/destination
{"state":"COMPLETED","statusCode":200,"message":"OK"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the plugin route still 401s:&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="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;DEVICE_TOKEN&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    http://localhost:3000/plugins/signalk-logbook/logs
401
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the maximally confusing state: the token &lt;em&gt;demonstrably works&lt;/em&gt; — for writes, even — so it never occurs to you that the token is the wrong &lt;em&gt;kind&lt;/em&gt;. It's not expired, not malformed, not missing a scope you can see anywhere. It just can't pass the admin gate, and nothing in the 401 body says so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2 — user JWT. Worked… for a while.&lt;/strong&gt; Once we read the routing code above, the fix seemed obvious: create a user account, log in, use that JWT:&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="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -d '{"username":"agent","password":"..."}' \
    http://localhost:3000/signalk/v1/auth/login | jq -r .token
eyJhbGciOiJIUzI1NiIs...

&lt;/span&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="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="gp"&gt;    -H "Authorization: Bearer $&lt;/span&gt;USER_JWT&lt;span class="s2"&gt;" ... &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    http://localhost:3000/plugins/signalk-logbook/logs
200
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Victory. Then, days later, the writes went dark &lt;strong&gt;again&lt;/strong&gt; — this time with a different string in the log:&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="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$USER_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    http://localhost:3000/plugins/signalk-logbook/logs
bad auth token
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clue was in the login response the whole time: &lt;code&gt;"timeToLive": 3600&lt;/code&gt;. User JWTs expire according to the server-side &lt;code&gt;expiration&lt;/code&gt; field in &lt;code&gt;security.json&lt;/code&gt;; the code falls back to &lt;code&gt;1h&lt;/code&gt; when it's unset, and whatever short value your install carries, your freshly minted "fix" dies with 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;// login() — src/tokensecurity.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;theExpiration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1h&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;isNever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;theExpiration&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;jwtOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresIn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;theExpiration&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;StringValue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Device tokens let you pick "NEVER" at approval time, right in the UI. User JWTs give you no such prompt — the TTL is a server-wide setting, and in the admin UI it hides under &lt;strong&gt;Security → Settings → "Remember Me timeout"&lt;/strong&gt; ("How long server keeps you logged when Remember Me is checked in login"). Nothing tells you that same field is the lifespan of every token &lt;code&gt;POST /signalk/v1/auth/login&lt;/code&gt; mints for your headless clients.&lt;/p&gt;

&lt;p&gt;And note it's now &lt;em&gt;worse&lt;/em&gt; than having no token: &lt;code&gt;allow_readonly&lt;/code&gt; only applies when &lt;strong&gt;no&lt;/strong&gt; token is sent. An expired token on the request 401s even plain reads:&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;// Token was provided but is invalid/revoked — always reject.&lt;/span&gt;
&lt;span class="c1"&gt;// allow_readonly only applies when no token is provided at all.&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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="s1"&gt;bad auth token&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;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two parts: a long-lived user JWT, and a re-mint procedure for when it eventually dies anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Set the server-side expiration, then mint.&lt;/strong&gt; Either edit &lt;code&gt;expiration&lt;/code&gt; in &lt;code&gt;security.json&lt;/code&gt; and restart the server (&lt;code&gt;~/.signalk/security.json&lt;/code&gt;; in Docker that's inside the container's home volume) —&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;"expiration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"365d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&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;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;docker restart signalk
&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="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -d '{"username":"agent","password":"..."}' \
    http://localhost:3000/signalk/v1/auth/login
{"timeToLive":31536000,"token":"eyJ..."}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— or skip the login endpoint entirely and mint a token offline with the &lt;a href="https://demo.signalk.org/documentation/setup/generating_tokens.html" rel="noopener noreferrer"&gt;bundled utility&lt;/a&gt;, which signs against &lt;code&gt;security.json&lt;/code&gt;'s &lt;code&gt;secretKey&lt;/code&gt; with whatever TTL you ask for:&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;signalk-generate-token &lt;span class="nt"&gt;-u&lt;/span&gt; agent &lt;span class="nt"&gt;-e&lt;/span&gt; 1y &lt;span class="nt"&gt;-s&lt;/span&gt; ~/.signalk/security.json
&lt;span class="go"&gt;eyJhbGciOiJIUzI1NiIs...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same "Remember Me timeout" field in Security → Settings edits the value from the UI if you'd rather not touch the file — just know that's the knob you're turning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Store the login creds next to the token, not just the token.&lt;/strong&gt; The token &lt;em&gt;will&lt;/em&gt; expire — a year out, or the day someone rotates &lt;code&gt;secretKey&lt;/code&gt;. Keep &lt;code&gt;SIGNALK_USER&lt;/code&gt; / &lt;code&gt;SIGNALK_PASSWORD&lt;/code&gt; in the client's env alongside the JWT, so re-minting is one &lt;code&gt;curl&lt;/code&gt; and not an archaeology dig. Better: have the client check &lt;code&gt;timeToLive&lt;/code&gt; at mint time and log the expiry date somewhere a human will see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Make the write path prove itself.&lt;/strong&gt; The actual root cause was never the token — it was a write path that could fail forever without anyone noticing, twice. Fire-and-forget writes to an authed endpoint need, at minimum, surfacing of non-2xx responses; ideally an end-to-end check that &lt;em&gt;reads back&lt;/em&gt; something it recently wrote. A healthy-looking read path tells you nothing about the write path when reads are anonymous and writes are authenticated — they don't even share an auth gate to fail together.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"The token works" is not "the token works here."&lt;/strong&gt; One server, two token classes, three permission levels, and per-route-class gates. Data writes check &lt;code&gt;readwrite&lt;/code&gt;; &lt;code&gt;/plugins/*&lt;/code&gt; and &lt;code&gt;/skServer/*&lt;/code&gt; check &lt;code&gt;admin&lt;/code&gt;. When you hit a 401 with a token that provably works elsewhere, ask &lt;em&gt;which middleware&lt;/em&gt; is in front of this route class before re-minting anything.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The expiration knob is disguised.&lt;/strong&gt; &lt;code&gt;security.json&lt;/code&gt;'s &lt;code&gt;expiration&lt;/code&gt; governs every user JWT the login endpoint mints, but the admin UI labels it "Remember Me timeout" and describes it purely in browser-login terms. If your headless client's JWT keeps dying, this is the field.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;An expired token is worse than none.&lt;/strong&gt; &lt;code&gt;allow_readonly&lt;/code&gt; grants anonymous &lt;em&gt;tokenless&lt;/em&gt; requests a readonly principal — but a request carrying an expired or bad token is always rejected, reads included (&lt;code&gt;bad auth token&lt;/code&gt;). A client that "falls back to readonly" by keeping its stale header set doesn't fall back at all.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pending access requests don't survive a restart.&lt;/strong&gt; They're held in an in-memory map on the server (&lt;code&gt;requestResponse.ts&lt;/code&gt;), so if you restart between a device requesting access and the admin approving it, the request is simply gone — the device has to ask again. Approved devices &lt;em&gt;are&lt;/em&gt; persisted to &lt;code&gt;security.json&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The browser gets token refresh; your client doesn't.&lt;/strong&gt; Past a JWT's half-life the server issues a refreshed token — as a session cookie. A headless client holding the raw bearer token from the login response never sees it, so browsers stay logged in indefinitely while your agent's identical token quietly ages out.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you're the one writing the plugin
&lt;/h2&gt;

&lt;p&gt;The same admin gate bites from the other side. A plugin that serves its data with &lt;code&gt;registerWithRouter&lt;/code&gt; puts that data behind &lt;code&gt;/plugins/&amp;lt;id&amp;gt;/*&lt;/code&gt; — admin-only, invisible to every anonymous or readwrite consumer, even with &lt;code&gt;allow_readonly&lt;/code&gt; on. If what you're serving is &lt;em&gt;data&lt;/em&gt; rather than admin actions, register a &lt;a href="https://demo.signalk.org/documentation/develop/plugins/resource_provider_plugins.html" rel="noopener noreferrer"&gt;resource provider&lt;/a&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerResourceProvider&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="s1"&gt;myThings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;listResources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;getResource&lt;/span&gt;&lt;span class="p"&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="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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;setResource&lt;/span&gt;&lt;span class="p"&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;value&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="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;deleteResource&lt;/span&gt;&lt;span class="p"&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="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="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 serves under &lt;code&gt;/signalk/v2/api/resources/myThings&lt;/code&gt; — reads are open under &lt;code&gt;allow_readonly&lt;/code&gt;, writes go through the normal &lt;code&gt;readwrite&lt;/code&gt; check, and no consumer ever needs an admin credential to look at your data. Save &lt;code&gt;registerWithRouter&lt;/code&gt; for genuinely admin-ish endpoints, or for plugins (like a logbook) whose API is deliberately write-capable and authed — and document which token class callers need, because the server's 401 won't.&lt;/p&gt;

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

&lt;p&gt;This all fell out of running an AI crew-agent that keeps the ship's log on an all-electric charter catamaran — the agent read the vessel state fine for a week while its log entries bounced off a gate nobody knew existed. The MCP server that now holds the long-lived JWT (and the re-mint procedure) is open source: &lt;a href="https://github.com/sailingnaturali/logbook-mcp" rel="noopener noreferrer"&gt;github.com/sailingnaturali/logbook-mcp&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-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 silent-401 failure shape, on the outbound notification leg · &lt;a href="https://dev.to%20post_url%202026-06-06-adopt-vs-build-ships-log-signalk-logbook-mcp%20"&gt;Adopt vs build: the ship's log&lt;/a&gt; — how the logbook stack this bit is put together.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>authentication</category>
      <category>jwt</category>
      <category>security</category>
    </item>
    <item>
      <title>How I proved a from-scratch harmonic tide engine is accurate</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 15 Aug 2026 16:39:18 +0000</pubDate>
      <link>https://dev.to/clarkbw--/how-i-proved-a-from-scratch-harmonic-tide-engine-is-accurate-13n6</link>
      <guid>https://dev.to/clarkbw--/how-i-proved-a-from-scratch-harmonic-tide-engine-is-accurate-13n6</guid>
      <description>&lt;p&gt;You wrote a tide engine. It computes a number: 1.42 m at 3:10 PM. How do you know&lt;br&gt;
that number is &lt;em&gt;right&lt;/em&gt;? You can't measure the future ocean. You can't diff against&lt;br&gt;
"the truth" because the truth is a prediction too. This is the whole problem with a&lt;br&gt;
from-scratch harmonic tide predictor — the output looks authoritative the day you&lt;br&gt;
first run it, and authoritative-looking is not the same as correct.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Validate a harmonic tide engine on two axes. &lt;strong&gt;Algorithm:&lt;/strong&gt; golden&lt;br&gt;
vectors against a trusted oracle (&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;) — every layer to&lt;br&gt;
floating-point agreement. &lt;strong&gt;Reality:&lt;/strong&gt; head-to-head against the tide authority's&lt;br&gt;
&lt;em&gt;own&lt;/em&gt; published predictions — NOAA CO-OPS at Friday Harbor, &lt;strong&gt;max 7.9 min / 3.5 cm&lt;/strong&gt;&lt;br&gt;
across 12 highs and lows, inside the ±15 min / ±0.15 m you'd accept from a printed&lt;br&gt;
table. And before you trust any of it: if your predictions read &lt;em&gt;higher&lt;/em&gt; than the&lt;br&gt;
printed tide tables, that's usually not a bug — it's the &lt;strong&gt;chart-datum offset&lt;/strong&gt;.&lt;br&gt;
Jump to the numbers.&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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Ferror-vs-tolerance.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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Ferror-vs-tolerance.svg" alt="Against NOAA CO-OPS predictions at Friday Harbor the engine's worst error over 12 highs and lows is 7.9 minutes and 3.5 cm, roughly half the plus-or-minus 15 minute and 0.15 m tolerance you would accept from a printed tide table." width="880" height="300"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The trap that makes a correct engine look wrong
&lt;/h2&gt;

&lt;p&gt;Here's the first thing that will convince you your engine is broken when it isn't.&lt;/p&gt;

&lt;p&gt;Compute the harmonic tide height for a station, line it up against the printed tide&lt;br&gt;
tables for the same minute, and your number comes out &lt;strong&gt;higher&lt;/strong&gt; — consistently,&lt;br&gt;
by a fixed amount. The instinct is to hunt for the bug. There isn't one.&lt;/p&gt;

&lt;p&gt;A harmonic sum is referenced to the datum its constituents were derived against —&lt;br&gt;
typically &lt;strong&gt;mean sea level (MSL)&lt;/strong&gt;, the average of all water levels. Printed tide&lt;br&gt;
tables and nautical charts are referenced to &lt;strong&gt;chart datum&lt;/strong&gt;: &lt;strong&gt;MLLW&lt;/strong&gt; (mean lower&lt;br&gt;
low water) in the US, &lt;strong&gt;LAT&lt;/strong&gt; (lowest astronomical tide) in Canada. Chart datum sits&lt;br&gt;
&lt;em&gt;below&lt;/em&gt; MSL by design — it's a low-water reference so that charted depths are&lt;br&gt;
conservative. So a raw harmonic prediction reads higher than the table by exactly the&lt;br&gt;
distance between the two datums.&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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Fchart-datum-offset.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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Fchart-datum-offset.svg" alt="A raw harmonic sum is referenced to mean sea level while printed tide tables use chart datum MLLW, so every unadjusted prediction reads high by exactly the published per-station MSL to MLLW offset." width="880" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is a single per-station offset. In this engine it's one optional field on the&lt;br&gt;
station:&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;station&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Station&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;HarmonicConstituent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"M2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;amplitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;phase&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="cm"&gt;/* … */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="nv"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.387&lt;/span&gt;  &lt;span class="c1"&gt;// datum offset (e.g. MSL → MLLW), optional&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that offset isn't a fudge factor — it's published metadata. NOAA ships the datum&lt;br&gt;
values per station; the offset is just their difference:&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;fh&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;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noaa/9449880&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;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MSL&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MLLW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shift MSL-relative harmonics to chart datum MLLW&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The apps that "match the printed number" without applying this are the ones actually&lt;br&gt;
carrying an error — they've hidden a datum mismatch by accident, or they never summed&lt;br&gt;
the harmonics in the first place. Once you know the offset exists, it's a knob, not a&lt;br&gt;
mystery. (This is a subtlety of the &lt;em&gt;harmonic-prediction path&lt;/em&gt; — computing heights&lt;br&gt;
from constituents versus reading a printed table. Hydrographic services like NOAA and&lt;br&gt;
CHS serve their own predictions at their own stated datum; they're not wrong, they're&lt;br&gt;
just answering a different question than a from-scratch sum does.)&lt;/p&gt;

&lt;p&gt;That's the trap. Now the actual validation.&lt;/p&gt;
&lt;h2&gt;
  
  
  Method: golden vectors against an oracle
&lt;/h2&gt;

&lt;p&gt;You can't test a predictor against the ocean. You &lt;em&gt;can&lt;/em&gt; test it against another&lt;br&gt;
predictor that's already trusted — and if the two agree to floating-point precision,&lt;br&gt;
your port of the algorithm is faithful by construction.&lt;/p&gt;

&lt;p&gt;The oracle here is &lt;a href="https://github.com/openwatersio/tide-predictor" rel="noopener noreferrer"&gt;&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
&lt;code&gt;@0.10.0&lt;/code&gt;, a well-exercised JavaScript harmonic engine. The Swift engine is a port of&lt;br&gt;
its algorithm, so the bar isn't "close" — it's &lt;em&gt;the same number&lt;/em&gt;. A Node tool&lt;br&gt;
generates golden fixtures from Neaps; &lt;code&gt;swift test&lt;/code&gt; replays them and asserts agreement&lt;br&gt;
layer by layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;swift &lt;span class="nb"&gt;test&lt;/span&gt;                    &lt;span class="c"&gt;# golden validation against the Neaps reference&lt;/span&gt;
node tools/gen-golden.mjs     &lt;span class="c"&gt;# regenerate golden fixtures from @neaps/tide-predictor&lt;/span&gt;
node tools/gen-realworld.mjs  &lt;span class="c"&gt;# refresh the NOAA real-world fixture&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of going layer by layer is that a tide prediction is a stack of&lt;br&gt;
computations, and an error in any one of them hides inside a plausible-looking final&lt;br&gt;
curve. Test the whole pipeline only end-to-end and a sign error in the node&lt;br&gt;
corrections can cancel against a phase error somewhere else for the 48 hours you&lt;br&gt;
happened to check. So each layer gets pinned independently:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Tolerance&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Astronomy&lt;/td&gt;
&lt;td&gt;mean longitudes + node angles, 8 times across the 18.6-yr nodal cycle&lt;/td&gt;
&lt;td&gt;1e-6°&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node corrections&lt;/td&gt;
&lt;td&gt;IHO f/u, 17 base constituents × 3 times&lt;/td&gt;
&lt;td&gt;1e-6&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constituents&lt;/td&gt;
&lt;td&gt;V₀ + compound f/u, ~39 constituents × 2 times&lt;/td&gt;
&lt;td&gt;1e-6&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prediction&lt;/td&gt;
&lt;td&gt;48 h height series, mixed-tide set&lt;/td&gt;
&lt;td&gt;&amp;lt; 1e-6 m&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extremes&lt;/td&gt;
&lt;td&gt;hi/lo count, kind, time, height&lt;/td&gt;
&lt;td&gt;60 s / 0.02 m&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few of these deserve a note on &lt;em&gt;why&lt;/em&gt; they're the layers that break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astronomy across the 18.6-year nodal cycle.&lt;/strong&gt; The moon's orbital plane precesses
over 18.6 years, and the mean longitudes and node angle are sampled across that full
cycle — not just at one date — because a bug in the node angle is invisible near one
epoch and large near another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node corrections (the f/u factors).&lt;/strong&gt; These are the per-constituent amplitude (&lt;code&gt;f&lt;/code&gt;)
and phase (&lt;code&gt;u&lt;/code&gt;) adjustments that account for that same nodal modulation. They're the
most common place a harmonic engine quietly drifts, which is why they get their own
fixture instead of only being checked through the final height.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extremes, not just the height curve.&lt;/strong&gt; Sampling heights on a grid and calling the
peaks "high tide" gives you the wrong minute. The extremes layer solves for the
actual turning points and is checked on count, kind (high vs low), time, and height
separately — the tolerances there (60 s, 0.02 m) are the tight ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every layer lands at floating-point agreement (&lt;code&gt;1e-6&lt;/code&gt;). The algorithm is faithful to&lt;br&gt;
the reference. But "faithful to the reference" only proves the port is correct — it&lt;br&gt;
says nothing about whether the &lt;em&gt;reference&lt;/em&gt; matches the real world. That's the second&lt;br&gt;
axis.&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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Fvalidation-harness.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%2Fvalidating-harmonic-tide-prediction-engine-chart-datum-mllw-msl-noaa-co-ops-golden-tests-swift%2Fvalidation-harness.svg" alt="The validation harness runs two independent comparisons: golden vectors against the Neaps tide predictor agree to 1e-6, proving the port is faithful, while the same engine with a per-station MSL to MLLW datum offset applied matches NOAA CO-OPS predictions to 7.9 minutes and 3.5 cm." width="880" height="440"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The datum offset is a step in the harness, not a footnote — it's the only thing standing between "agrees to a few centimetres" and "off by the whole MSL-to-MLLW gap."&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The receipt: vs NOAA's own predictions
&lt;/h2&gt;

&lt;p&gt;The stronger test is against a completely independent authority: NOAA's own published&lt;br&gt;
predictions, straight from the &lt;a href="https://api.tidesandcurrents.noaa.gov/api/prod/" rel="noopener noreferrer"&gt;CO-OPS Data Retrieval&lt;br&gt;
API&lt;/a&gt;. This is a real head-to-head —&lt;br&gt;
feed the engine the published harmonic constituents for a station, then compare its&lt;br&gt;
highs and lows to what NOAA's &lt;em&gt;own&lt;/em&gt; internal engine says for the same station and days.&lt;/p&gt;

&lt;p&gt;Station: &lt;strong&gt;Friday Harbor, WA (NOAA 9449880)&lt;/strong&gt;. Constituents come from&lt;br&gt;
&lt;a href="https://github.com/openwatersio/tide-database" rel="noopener noreferrer"&gt;&lt;code&gt;@neaps/tide-database&lt;/code&gt;&lt;/a&gt; (sourced from&lt;br&gt;
NOAA, public domain, bundled offline). The comparison target is fetched live from&lt;br&gt;
CO-OPS at MLLW, GMT:&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.tidesandcurrents.noaa.gov/api/prod/datagetter`&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;`?begin_date=20260715&amp;amp;end_date=20260717`&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;`&amp;amp;station=9449880&amp;amp;product=predictions&amp;amp;datum=MLLW&amp;amp;interval=hilo`&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;`&amp;amp;units=metric&amp;amp;time_zone=gmt&amp;amp;format=json`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve highs and lows over 2026-07-15…17:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Engine vs NOAA&lt;/th&gt;
&lt;th&gt;Navigational tolerance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Max time error&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.9 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;±15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max height error&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5 cm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;±0.15 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The engine reproduces NOAA's published tide tables to a few minutes and a few&lt;br&gt;
centimetres — inside half the tolerance you'd accept from a paper table, on both axes,&lt;br&gt;
computed entirely offline from the bundled constants. That's the claim that matters:&lt;br&gt;
not "agrees with the code I ported from," but "agrees with the tide authority."&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest residual — why ~8 minutes
&lt;/h2&gt;

&lt;p&gt;I'm not going to tell you the error is zero, because it isn't, and the residual is&lt;br&gt;
worth understanding rather than papering over.&lt;/p&gt;

&lt;p&gt;The ~8 min / 3.5 cm gap against CO-OPS is &lt;strong&gt;expected&lt;/strong&gt;. NOAA's operational engine&lt;br&gt;
isn't the same engine — it uses a different node-correction epoch and a somewhat&lt;br&gt;
different constituent set than the Neaps algorithm this port follows. Two correct&lt;br&gt;
harmonic engines, fed the same station's constants but differing in those internals,&lt;br&gt;
will land a few minutes apart. That's not error accumulating toward a wrong answer;&lt;br&gt;
it's the known spread between two legitimate methods, and it sits an order of magnitude&lt;br&gt;
inside navigational tolerance.&lt;/p&gt;

&lt;p&gt;The tell that it's method-spread and not a bug: against the Neaps oracle — same&lt;br&gt;
algorithm — the agreement is &lt;code&gt;1e-6&lt;/code&gt;, essentially exact. Against NOAA — different&lt;br&gt;
algorithm — it's minutes. If the port were broken, the first number wouldn't be&lt;br&gt;
floating-point tight. The two results are consistent with exactly one story: a faithful&lt;br&gt;
port of one correct method, differing from another correct method by the amount two&lt;br&gt;
correct methods differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on data sourcing
&lt;/h2&gt;

&lt;p&gt;The constituents themselves are public. NOAA harmonic constants are public domain and&lt;br&gt;
bundled with the engine (~3400 stations via &lt;code&gt;@neaps/tide-database&lt;/code&gt;), which is what makes&lt;br&gt;
offline prediction possible at all — no network, no key, no live dependency, predictions&lt;br&gt;
years ahead from data that ships with the app. For Canadian waters, CHS publishes&lt;br&gt;
&lt;em&gt;predictions&lt;/em&gt; (not just constants) through its IWLS API, fetched and cached online.&lt;/p&gt;

&lt;p&gt;One gotcha worth flagging for anyone bundling multi-source constants: &lt;strong&gt;constituent&lt;br&gt;
naming isn't standardized&lt;/strong&gt;. NOAA writes &lt;code&gt;NU2&lt;/code&gt;, &lt;code&gt;MM&lt;/code&gt;, &lt;code&gt;RHO&lt;/code&gt;; CHS and other sources use&lt;br&gt;
their own variants for the same physical constituents. Feed a station's published&lt;br&gt;
constants straight in and the ones whose names don't match your catalog silently drop&lt;br&gt;
out of the sum — a prediction that's subtly, plausibly wrong. The engine's catalog&lt;br&gt;
resolves &lt;strong&gt;83 aliases&lt;/strong&gt; to canonical names so published constants from any source&lt;br&gt;
predict correctly.&lt;/p&gt;

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

&lt;p&gt;A tide number you computed yourself is only worth anything if you can show your work.&lt;br&gt;
Two axes do it: golden vectors prove the algorithm is a faithful port, and a&lt;br&gt;
head-to-head against the authority's own predictions proves the port matches reality —&lt;br&gt;
with the datum offset applied so you're comparing to the right zero. The engine is&lt;br&gt;
MIT-licensed and the validation is reproducible from the fixtures:&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/slackwater-engine" rel="noopener noreferrer"&gt;&lt;strong&gt;slackwater-engine&lt;/strong&gt;&lt;/a&gt;. If it's&lt;br&gt;
off in your home waters, the fixtures are right there — check it and send a number back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not for navigation.&lt;/strong&gt; Predictions are astronomical estimates and don't account for&lt;br&gt;
weather, surge, or local effects. Carry official tables and charts.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tides</category>
      <category>swift</category>
      <category>harmonicanalysis</category>
      <category>noaa</category>
    </item>
    <item>
      <title>Tide predictions read higher than the tables? Suspect the datum</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:11:11 +0000</pubDate>
      <link>https://dev.to/clarkbw--/tide-predictions-read-higher-than-the-tables-suspect-the-datum-2jmn</link>
      <guid>https://dev.to/clarkbw--/tide-predictions-read-higher-than-the-tables-suspect-the-datum-2jmn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — If a harmonic tide prediction disagrees with the official tide&lt;br&gt;
tables by a &lt;em&gt;constant&lt;/em&gt; offset at a station, the algorithm is fine; the two&lt;br&gt;
heights are measured from different zeros. Canadian chart datum is &lt;strong&gt;not&lt;/strong&gt;&lt;br&gt;
LAT — at CHS stations it sits up to ~0.43 m above it, and the per-station&lt;br&gt;
offset is published in the CHS station &lt;code&gt;/metadata&lt;/code&gt; API.&lt;br&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%2Fharmonic-tide-predictions-higher-than-tide-tables-chart-datum-vs-lat-chs-datum-offset-signalk-tides-neaps%2Fdatum-normalized-error.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%2Fharmonic-tide-predictions-higher-than-tide-tables-chart-datum-vs-lat-chs-datum-offset-signalk-tides-neaps%2Fdatum-normalized-error.svg" alt="Worst-case height error against the official CHS tide tables, per station, before and after normalizing both sides to LAT. Sidney BC falls from 0.35 to 0.48 m down to 8 cm or less, Point Atkinson from 0.13 m to 5 cm, Halifax from 0.38 m to 13 cm." width="880" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tide height numbers are meaningless without a stated vertical datum. Every&lt;br&gt;
"3.2 m at 14:40" is really "3.2 m &lt;em&gt;above some agreed zero&lt;/em&gt;", and there are&lt;br&gt;
half a dozen zeros in circulation: mean sea level (MSL), mean lower low water&lt;br&gt;
(MLLW), lowest astronomical tide (LAT), and each hydrographic office's own&lt;br&gt;
chart datum. Compare two predictions that don't share a zero and you get a&lt;br&gt;
bug report that looks exactly like broken code.&lt;/p&gt;

&lt;p&gt;This is the story of one of those bug reports — against&lt;br&gt;
&lt;a href="https://github.com/openwatersio/signalk-tides" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-tides&lt;/code&gt;&lt;/a&gt;, the SignalK&lt;br&gt;
tide plugin — that turned out to be a datum mismatch, and of the&lt;br&gt;
cross-checking that then turned up a genuinely broken station in the upstream&lt;br&gt;
tide database.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;signalk-tides&lt;/code&gt; v2 (&lt;a href="https://github.com/openwatersio/signalk-tides/releases" rel="noopener noreferrer"&gt;2.0.0-beta.1&lt;/a&gt;,&lt;br&gt;
after &lt;a href="https://github.com/openwatersio/signalk-tides/pull/78" rel="noopener noreferrer"&gt;#78&lt;/a&gt; went&lt;br&gt;
Neaps-only) predicts tides &lt;strong&gt;offline&lt;/strong&gt; from harmonic constituents — no NOAA,&lt;br&gt;
no WorldTides, no API keys. It runs on our boat's SignalK server and answers&lt;br&gt;
for any position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"http://&amp;lt;signalk-host&amp;gt;:3000/signalk/v2/api/tides/extremes?latitude=48.649&amp;amp;longitude=-123.393"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cross-checking it against the official CHS predictions for Sidney, BC&lt;br&gt;
(station 07260) before trusting it for anchoring math:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validated against official CHS predictions, 2026-06-12

Sidney BC (07260):          every high and low +0.35 to +0.48 m HIGH
                            timing within ~16 min
Point Atkinson BC (07795):  heights within 0.03–0.13 m
                            timing within ~6 min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At Sidney, every single high and low read a consistent third-to-half metre&lt;br&gt;
&lt;strong&gt;higher&lt;/strong&gt; than the published tables — while Point Atkinson, checked the same&lt;br&gt;
way on the same day, was nearly spot on. Timing was fine at both. The obvious&lt;br&gt;
conclusion — the one any user would file&lt;br&gt;
as an issue — is "the plugin over-reads by half a metre; the harmonic engine&lt;br&gt;
is broken."&lt;/p&gt;

&lt;p&gt;The same symptom was already sitting in the Neaps tracker as&lt;br&gt;
&lt;a href="https://github.com/openwatersio/neaps/issues/223" rel="noopener noreferrer"&gt;openwatersio/neaps#223&lt;/a&gt;,&lt;br&gt;
with a Halifax example:&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;// Neaps (TICON constituents), Halifax NS, 2026-02-20, datum: "MLLW"&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fri, 20 Feb 2026 01:38:10 GMT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;1.507&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fri, 20 Feb 2026 07:51:58 GMT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.068&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fri, 20 Feb 2026 13:51:17 GMT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="mf"&gt;1.474&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Fri, 20 Feb 2026 20:08:29 GMT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.136&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 javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CHS official wlp-hilo predictions, same station, same day&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eventDate&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;2026-02-20T01:36:00Z&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;value&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.817&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;eventDate&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;2026-02-20T07:57:00Z&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;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.310&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;eventDate&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;2026-02-20T13:46:00Z&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;value&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.774&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;eventDate&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;2026-02-20T20:09:00Z&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;value&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.181&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Times agree to minutes. Levels are ~0.3 m apart — and &lt;em&gt;uniformly&lt;/em&gt; apart.&lt;/p&gt;

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

&lt;p&gt;That uniformity is the whole diagnosis. Harmonic synthesis produces an&lt;br&gt;
oscillation about the station's &lt;strong&gt;mean water level&lt;/strong&gt;; getting from there to&lt;br&gt;
"height above chart datum" is a per-station constant from a datum table, added&lt;br&gt;
at the end. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;constituent&lt;/strong&gt; error (bad amplitudes/phases) varies with the tide — big
at springs, small at neaps, different at highs vs lows.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;datum&lt;/strong&gt; error is DC — the same offset on every prediction, at every
tide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A constant offset means the sine waves are right and the &lt;em&gt;zero&lt;/em&gt; is wrong.&lt;/p&gt;

&lt;p&gt;Here the zeros genuinely differ. Neaps serves heights in the station's&lt;br&gt;
&lt;code&gt;chart_datum&lt;/code&gt; from the &lt;a href="https://github.com/openwatersio/tide-database" rel="noopener noreferrer"&gt;TICON-based tide database&lt;/a&gt;,&lt;br&gt;
which labels Canadian stations &lt;code&gt;"LAT"&lt;/code&gt; (lowest astronomical tide). CHS tide&lt;br&gt;
tables are referenced to &lt;strong&gt;CHS chart datum&lt;/strong&gt;. And the common assumption that&lt;br&gt;
Canadian chart datum ≈ LAT turns out to be false at station level: CHS chart&lt;br&gt;
datum sits &lt;strong&gt;above&lt;/strong&gt; LAT by a per-station amount — 0.43 m at Sidney.&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%2Fharmonic-tide-predictions-higher-than-tide-tables-chart-datum-vs-lat-chs-datum-offset-signalk-tides-neaps%2Fsidney-datum-ladder.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%2Fharmonic-tide-predictions-higher-than-tide-tables-chart-datum-vs-lat-chs-datum-offset-signalk-tides-neaps%2Fsidney-datum-ladder.svg" alt="Vertical datum ladder at Sidney BC: CHS chart datum, the zero of the tide tables, sits 0.43 m above lowest astronomical tide, the zero of the harmonic record, so the same water surface is reported 0.43 m higher by the harmonic engine." width="880" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You don't have to take that on faith; CHS publishes every station's vertical&lt;br&gt;
ladder in the IWLS &lt;code&gt;/metadata&lt;/code&gt; endpoint:&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;# Sidney BC (station code 07260)&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/5cebf1df3d0f4a073c4bbd26/metadata"&lt;/span&gt;
&lt;span class="c"&gt;# resolve the heightTypeIds via:&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/height-types"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// metadata "heights", values in metres relative to CHS chart datum&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MWL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"value"&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.09&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="c1"&gt;// mean water level&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LLWLT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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.22&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="c1"&gt;// lower low water, large tide&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LAT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"value"&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.43&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="c1"&gt;// lowest astronomical tide&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HAT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="mf"&gt;3.53&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;LAT is 0.43 m &lt;em&gt;below&lt;/em&gt; chart datum at Sidney — exactly the amount by which the&lt;br&gt;
plugin's LAT-referenced predictions "over-read" against the chart-datum&lt;br&gt;
tables. (Note LLWLT ≠ 0 either: even the textbook definition of Canadian&lt;br&gt;
chart datum doesn't sit at the datum's actual zero here.)&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Suspecting the constituents.&lt;/strong&gt; The natural first read — offline harmonics&lt;br&gt;
from a community database vs the hydrographic office, of course the&lt;br&gt;
constituents are worse. But lining up TICON's own datum table against CHS's&lt;br&gt;
published offsets shows the two agree almost exactly on where MSL sits above&lt;br&gt;
LAT:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Station&lt;/th&gt;
&lt;th&gt;TICON MSL→LAT&lt;/th&gt;
&lt;th&gt;CHS metadata (MWL above CD + CD above LAT)&lt;/th&gt;
&lt;th&gt;agreement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sidney BC&lt;/td&gt;
&lt;td&gt;2.522 m&lt;/td&gt;
&lt;td&gt;2.09 + 0.43 = 2.52 m&lt;/td&gt;
&lt;td&gt;~2 mm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point Atkinson BC&lt;/td&gt;
&lt;td&gt;3.144 m&lt;/td&gt;
&lt;td&gt;3.09 + 0.08 = 3.17 m&lt;/td&gt;
&lt;td&gt;~3 cm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Halifax NS&lt;/td&gt;
&lt;td&gt;1.152 m&lt;/td&gt;
&lt;td&gt;1.03 + 0.09 = 1.12 m&lt;/td&gt;
&lt;td&gt;~3 cm&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The physics agreed to centimetres. It was bookkeeping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparing "MLLW to MLLW".&lt;/strong&gt; The Halifax example above asked Neaps for&lt;br&gt;
&lt;code&gt;datum: "MLLW"&lt;/code&gt; and still missed by 0.3 m — because CHS &lt;code&gt;wlp-hilo&lt;/code&gt; values&lt;br&gt;
aren't MLLW-referenced (they're chart datum), and TICON's MLLW is its own&lt;br&gt;
computed statistic. Two datums with the same &lt;em&gt;name&lt;/em&gt; from two sources are not&lt;br&gt;
the same zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the &lt;code&gt;chart_datum: "LAT"&lt;/code&gt; label.&lt;/strong&gt; The station records say chart&lt;br&gt;
datum is LAT, so diffing against the tables should be safe… except CHS chart&lt;br&gt;
datum isn't LAT (see above). A label is a claim, not a measurement.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Normalize both sides to one explicit datum before comparing — or before doing&lt;br&gt;
any arithmetic at all with someone else's heights:&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;// CHS /metadata "heights": LAT is relative to chart datum (negative)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;heights&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;h&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;h&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="s2"&gt;LAT&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="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Sidney: -0.43&lt;/span&gt;

&lt;span class="c1"&gt;// shift CHS chart-datum predictions onto LAT…&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chsAboveLAT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chsPrediction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// …and ask the harmonic side for the same zero explicitly&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;extremes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;station&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtremesPrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;datum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LAT&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;Datum-aware, the "half a metre of error" collapses: Sidney residuals drop to&lt;br&gt;
≤ ~8 cm, Point Atkinson ≤ ~5 cm, Halifax ~10–13 cm, timing within minutes&lt;br&gt;
everywhere.&lt;/p&gt;

&lt;p&gt;That normalization then became a standing benchmark, contributed upstream as&lt;br&gt;
&lt;a href="https://github.com/openwatersio/neaps/pull/272" rel="noopener noreferrer"&gt;openwatersio/neaps#272&lt;/a&gt;: all&lt;br&gt;
226 Canadian MEDS-sourced TICON stations diffed against live CHS predictions&lt;br&gt;
(the TICON ids embed the CHS station code — &lt;code&gt;sidney_bc-7260-can-meds&lt;/code&gt; —&lt;br&gt;
so the join is free). Across the 180 stations that line up, full-year&lt;br&gt;
height MAE is &lt;strong&gt;p50 7.3 cm / p90 16.4 cm / p95 25.7 cm&lt;/strong&gt;, timing ~23 min&lt;br&gt;
(p95 of median |Δt|), re-run monthly in CI against fresh CHS predictions.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bonus find: a station no datum can explain
&lt;/h2&gt;

&lt;p&gt;The benchmark's per-station CSV kicked out one outlier that is &lt;em&gt;not&lt;/em&gt; a datum&lt;br&gt;
problem: &lt;code&gt;cape_dor-240-can-meds&lt;/code&gt; (Cape d'Or, Bay of Fundy) carries a &lt;strong&gt;+3.5 m&lt;br&gt;
constant bias&lt;/strong&gt; — and unlike the others, its harmonic record itself is&lt;br&gt;
implausible. Filed upstream as&lt;br&gt;
&lt;a href="https://github.com/openwatersio/tide-database/issues/93" rel="noopener noreferrer"&gt;openwatersio/tide-database#93&lt;/a&gt;&lt;br&gt;
(still open as of this writing):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TICON HAT−LAT:              18.58 m
CHS published HAT−LAT:      12.40 m   (same location)
one-year predicted range:   17.4 m    — would exceed the world-record
                                        range at Burntcoat Head
SA constituent:             2.08 m    — 4× the next-highest Canadian
                                        station; implausible for an
                                        open-marine site
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping the suspect SA term only brings the predicted range down to 16.0 m,&lt;br&gt;
so the whole constituent set reads as over-amplified, not one bad row. The&lt;br&gt;
point isn't this station — it's that &lt;strong&gt;benchmarking against published ground&lt;br&gt;
truth catches upstream data errors, not just your own bugs&lt;/strong&gt;. Nothing in the&lt;br&gt;
code path was wrong; the input data was.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never trust a height without asking "above what?"&lt;/strong&gt; Any pipeline that
passes tide heights between systems should carry the datum with the number,
and any comparison should normalize first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The error signature tells you where to look.&lt;/strong&gt; Constant offset across all
tides → datum/bookkeeping. Error that scales with the tide → constituents.
This one rule would have saved the original "water levels differ from the
public tide predictors" investigation a lot of head-scratching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The optimistic direction is the unsafe one.&lt;/strong&gt; Charted depths are below
chart datum. Add a LAT-referenced height to a charted depth where chart
datum sits 0.4 m above LAT and you believe in 0.4 m of water that isn't
there — exactly the wrong direction for under-keel clearance. Our agent
tooling carries this as an explicit caveat and pads margins accordingly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-station, not a constant.&lt;/strong&gt; The CHS chart-datum-to-LAT offset is 0.43 m
at Sidney, 0.08 m at Point Atkinson, 0.09 m at Halifax. There is no single
fudge factor; the station &lt;code&gt;/metadata&lt;/code&gt; is the source of truth.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This came out of wiring &lt;code&gt;signalk-tides&lt;/code&gt; v2 into the AI-ops stack we run on an&lt;br&gt;
all-electric sailing catamaran — offline tide predictions the boat can answer&lt;br&gt;
from with no internet, which is only useful if you've checked them against&lt;br&gt;
the official tables first. The plugin, the&lt;br&gt;
&lt;a href="https://github.com/openwatersio/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt; engine, and the&lt;br&gt;
&lt;a href="https://github.com/openwatersio/tide-database" rel="noopener noreferrer"&gt;tide database&lt;/a&gt; are all open&lt;br&gt;
source and better for the benchmarking.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; verifying forecasts against what a buoy is actually measuring is&lt;br&gt;
the same habit applied to weather — see&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;why generic weather MCPs fail for marine navigation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>tides</category>
      <category>navigation</category>
    </item>
    <item>
      <title>Shipping Canadian CHS station data without redistributing the licensed file</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:01:11 +0000</pubDate>
      <link>https://dev.to/clarkbw--/shipping-canadian-chs-station-data-without-redistributing-the-licensed-file-584a</link>
      <guid>https://dev.to/clarkbw--/shipping-canadian-chs-station-data-without-redistributing-the-licensed-file-584a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — US tide/current station data (NOAA) is public domain and you can bundle it into anything. Canadian data (CHS) is not: the IWLS API licence forbids redistributing derivative products. The facts inside — names, positions — aren't copyrightable anywhere (Feist, CCH Canadian), and there's no EU-style database right in the US or Canada. So the constraint isn't copyright, it's the &lt;em&gt;terms of use&lt;/em&gt; — a contract. The safe design ships your own independently-obtained registry that carries &lt;strong&gt;no provider-minted station id&lt;/strong&gt;, and resolves CHS's opaque handle at runtime under your own API licence, joining by station &lt;strong&gt;name&lt;/strong&gt;. Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A quick disclaimer, said once: I'm an engineer reasoning about licences, not a lawyer, and none of this is legal advice. But the reasoning is the whole point of the post — if you're building a marine, mapping, or civic-data app on government open data and you hit "can I redistribute this?", the shape of the answer is worth having.&lt;/p&gt;

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

&lt;p&gt;We predict tidal currents offline for a boat in the Salish Sea, which straddles the Canada/US border. Both national hydrographic offices publish the harmonic data that makes offline prediction possible. One of them lets you ship it. The other does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;US — NOAA — public domain.&lt;/strong&gt; NOAA publishes tidal-current harmonic constituents as public-domain data. Anyone can bundle them into anything, commercial or not, and offline prediction just works. There is no problem to solve here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canada — CHS — a bespoke Crown licence.&lt;/strong&gt; The &lt;a href="https://tides.gc.ca/en/licence-agreement" rel="noopener noreferrer"&gt;CHS licence agreement&lt;/a&gt; you accept by using the IWLS API is &lt;em&gt;not&lt;/em&gt; the &lt;a href="https://open.canada.ca/en/open-government-licence-canada" rel="noopener noreferrer"&gt;Open Government Licence&lt;/a&gt;. Three clauses bite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clause 3  — CHS copyrights "shall not be sold, licensed, leased, assigned
            or given to a third party."
Clause 4  — prohibits derivative products "for commercial purposes, for
            sale or profit under any form whatsoever."
Clause 10 — permits non-profit derivative products, provided you carry a
            prominent notice (your name, that it contains CHS IP, Crown
            copyright retained, not for navigation, CHS does not endorse it).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clause 10 lets &lt;em&gt;you&lt;/em&gt; derive data for your own non-commercial use. Clause 3 stops anyone from handing you — or you handing anyone — a finished bundle. So you cannot ship a Canadian tide/current dataset the way you ship a NOAA one.&lt;/p&gt;

&lt;p&gt;That leaves a design question with a non-obvious answer: &lt;strong&gt;how do you ship a Canadian tide/current app — a station picker, a slack-window planner, a registry of gates — without redistributing CHS's file?&lt;/strong&gt; The naive read is "you can't." The precise read is "you can ship everything except the one thing that is actually theirs."&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: copyright vs. contract
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for copyright — "are these facts protected?" — and that instinct sends you down the wrong road, because the answer is &lt;em&gt;no&lt;/em&gt;, and it doesn't help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Facts are not copyrightable. Two courts, two jurisdictions, say so directly.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;US&lt;/strong&gt; — &lt;a href="https://supreme.justia.com/cases/federal/us/499/340/" rel="noopener noreferrer"&gt;&lt;em&gt;Feist Publications v. Rural Telephone Service&lt;/em&gt;, 499 U.S. 340 (1991)&lt;/a&gt;. A phone book's white pages got &lt;strong&gt;zero&lt;/strong&gt; copyright protection. Compiling facts, however laborious, is not authorship; "sweat of the brow" was explicitly rejected. Only an &lt;em&gt;original selection, coordination, or arrangement&lt;/em&gt; is thinly protected, and the underlying facts stay free for anyone to re-extract.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canada&lt;/strong&gt; — &lt;a href="https://www.canlii.org/en/ca/scc/doc/2004/2004scc13/2004scc13.html" rel="noopener noreferrer"&gt;&lt;em&gt;CCH Canadian v. Law Society of Upper Canada&lt;/em&gt;, 2004 SCC 13&lt;/a&gt;. Originality requires "skill and judgment," not mere labour. A factual list with an obvious arrangement is not protected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And neither the US nor Canada has an EU-style &lt;em&gt;sui generis&lt;/em&gt; database right, so the labour of assembling a station list creates no separate right, either. A station's name, latitude, and longitude are facts. You may re-state them from a chart, a gazetteer, or your own survey, and nobody owns your copy.&lt;/p&gt;

&lt;p&gt;So copyright isn't the risk. &lt;strong&gt;The risk is contract.&lt;/strong&gt; The IWLS licence is terms-of-use you &lt;em&gt;agreed to&lt;/em&gt; in exchange for API access, and that agreement is separable from — and survives — the fact that the data inside is free. You can lawfully re-state every fact CHS knows; you cannot redistribute the &lt;em&gt;file you pulled from them under their licence&lt;/em&gt;. The line isn't "which facts appear," it's &lt;strong&gt;"did you hand someone a copy of the provider's licensed product."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which turns the whole problem into a question of &lt;em&gt;method&lt;/em&gt;, and puts a spotlight on one specific field.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tell: the opaque station id
&lt;/h3&gt;

&lt;p&gt;Every CHS station has an internal handle — an opaque id like &lt;code&gt;63aef18…&lt;/code&gt; — that you fetch its data with. It looks like just another fact, and copyright-wise it &lt;em&gt;is&lt;/em&gt; one. But it's a fact with a special property: &lt;strong&gt;it has no independent existence.&lt;/strong&gt; You cannot derive a CHS station id from a chart or a gazetteer. The only place it comes from is CHS's licensed API. So a file that carries the id is, by construction, a slice of CHS's export — it is the one field that can &lt;em&gt;only&lt;/em&gt; have been redistributed, and the one field that points straight back into their system.&lt;/p&gt;

&lt;p&gt;That is the field to not ship.&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%2Fcanadian-chs-tide-current-station-data-licensing-no-provider-id-runtime-name-correlation-feist-cch%2Fregistry-record-fields.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%2Fcanadian-chs-tide-current-station-data-licensing-no-provider-id-runtime-name-correlation-feist-cch%2Fregistry-record-fields.svg" alt="A station-corrections registry record ships four independently obtainable facts — name, context, position and provider — and deliberately carries no provider-minted station id, because the CHS id has no source other than the licensed IWLS API and is resolved at runtime instead." width="880" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it crossed the line)
&lt;/h2&gt;

&lt;p&gt;The honest version of this section is that our own earlier releases did the wrong thing, and we caught it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1 — ship the id as "just the join key."&lt;/strong&gt; Version 1.5.0 of our station registry carried &lt;code&gt;providerId&lt;/code&gt; on every record, treating it as harmless reference data — the handle a consumer needs to look up live current data:&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="c1"&gt;# station-corrections registry.yaml — v1.5.0 (the version we walked back)&lt;/span&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;span class="c1"&gt;# ← CHS's opaque handle, redistributed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the downstream fitting pipeline read exactly that field to fetch with:&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;// chs-constituents — the coupling we later removed&lt;/span&gt;
&lt;span class="nx"&gt;registry&lt;/span&gt; &lt;span class="nx"&gt;providerId&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;StationRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;    &lt;span class="c1"&gt;// what IwlsClient actually fetches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;code&gt;providerId&lt;/code&gt; is the one field on the record that isn't independently obtainable. &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;context&lt;/code&gt;, and &lt;code&gt;position&lt;/code&gt; we wrote ourselves from charts and our own fitting pipeline — those are our facts that happen to agree with CHS. But the id could only have come from CHS's API. Bundling it into a published npm package is handing a third party a piece of CHS's licensed export — precisely what clause 3 forbids ("given to a third party"). "It's just a reference" doesn't save it; the reference &lt;em&gt;is&lt;/em&gt; the redistribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2 — rationalize it as a bare fact.&lt;/strong&gt; "But the id is a fact, and Feist says facts are free." True, and irrelevant: copyright was never the live risk. Terms-of-use is contract, and the contract restricts redistributing the file regardless of whether its contents are copyrightable. Winning the copyright argument doesn't win the licence argument. And there's a compilation-copyright trap hiding in the same instinct — see the gotchas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3 — the "just ship the whole CHS station table" temptation.&lt;/strong&gt; Completeness &lt;em&gt;feels&lt;/em&gt; safe ("it's a mirror of a public API"). It's the opposite: a complete list is the weakest possible compilation-copyright position (no original &lt;em&gt;selection&lt;/em&gt; left to protect), and it maximizes the terms-of-use exposure by shipping the most of their file. Both readings point the same way — don't mirror their table.&lt;/p&gt;

&lt;p&gt;The fix isn't to cite a case. It's to change the method so the provider's handle never enters the repository at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a pipeline, not a download
&lt;/h2&gt;

&lt;p&gt;Here's the part worth internalizing before the code. On the US side, "add offline tide/current prediction" is essentially &lt;strong&gt;one API call&lt;/strong&gt; against a public-domain endpoint — fetch, bundle, done. On the Canadian side the same feature takes a &lt;strong&gt;chain of pieces&lt;/strong&gt;, each of which exists specifically to keep the licensed handle out of anything you publish:&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%2Fcanadian-chs-tide-current-station-data-licensing-no-provider-id-runtime-name-correlation-feist-cch%2Fus-vs-canada-pipeline.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%2Fcanadian-chs-tide-current-station-data-licensing-no-provider-id-runtime-name-correlation-feist-cch%2Fus-vs-canada-pipeline.svg" alt="Offline tide and current data is one public-domain call on the NOAA side, while the licensed Canadian CHS side takes three stages: chs-constituents resolves the CHS station id at runtime under the operator's own IWLS licence, the published station-corrections registry carries no provider id at all, and consumers correlate to live data by station name." width="880" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The registry in the middle is the linchpin: it's the only stage that gets &lt;em&gt;published&lt;/em&gt;, so it's the stage that must carry zero of CHS's file. The two ends touch CHS's licensed API — but only at runtime, on the operator's own machine, under the operator's own licence. A builder's real takeaway is that the Canadian version needs the whole chain; there's no single package that is both useful and shippable, because "useful" means resolving the licensed handle and "shippable" means not carrying it.&lt;/p&gt;

&lt;p&gt;Two moves make each stage clean: &lt;strong&gt;remove the id from the published data, and resolve it at runtime under the operator's own licence.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The registry ships facts and zero provider ids
&lt;/h3&gt;

&lt;p&gt;Version 2.0.0 dropped &lt;code&gt;providerId&lt;/code&gt; (and a NOAA depth-cell &lt;code&gt;providerBin&lt;/code&gt;) from the schema, the data, and the tests — a deliberate breaking change:&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="c1"&gt;# station-corrections registry.yaml — v2.0.0&lt;/span&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="c1"&gt;# the string "chs"/"noaa" — a fact, identifies the authority&lt;/span&gt;
  &lt;span class="c1"&gt;# no providerId. The stable key `chs-dodd-narrows` is the public id, safe in a URL.&lt;/span&gt;
&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;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt;
&lt;span class="gp"&gt;50c740a docs: registry ships no provider id;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;bump to 2.0.0
&lt;span class="go"&gt;46410cf feat!: remove providerId/providerBin from registry data and fixtures
8dfea91 feat!: drop providerId/providerBin from the registry schema and validation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything left is a fact we obtained independently and reviewed by hand: the name (re-cased and cleaned from a shouting survey label), the position (from our own fitting pipeline and charts, audited against a coastline), the context. The &lt;code&gt;provider&lt;/code&gt; string stays — "this is a CHS authority station" is itself a fact. The &lt;strong&gt;public join key is the station name&lt;/strong&gt; (and its stable &lt;code&gt;slug&lt;/code&gt;), which anyone is free to publish.&lt;/p&gt;

&lt;p&gt;That "deliberately absent" id is the load-bearing move, so it's worth stating how the package itself frames it. Its &lt;a href="https://github.com/sailingnaturali/station-corrections/blob/main/PROVENANCE.md" rel="noopener noreferrer"&gt;&lt;code&gt;PROVENANCE.md&lt;/code&gt;&lt;/a&gt; records provenance field by field — name is a hand-written label, context is written here, position is independently derived and human-verified — and then, for the provider id, one row that just reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;provider id — Deliberately absent.&lt;/strong&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;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The registry isn't only used as an id-less lookup table, either — it's a four-tier resolver, so a consumer with &lt;em&gt;any&lt;/em&gt; station reference lands on the same curated identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Registry        — stations this package owns outright (CHS gates with no
                     upstream record to correct). Resolves from a key alone.
2. Curated override — a hand-written correction wins over the provider's own name.
3. Derived fallback — nearest place from a bundled gazetteer, so context is never empty.
4. Source data      — the provider's own name, cleaned (upper-case → title-case).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the four tiers stores or emits a provider id. The dance is exactly this: tiers 1–4 traffic only in facts (names, positions, contexts) that agree with CHS &lt;em&gt;because facts agree with facts&lt;/em&gt; — never because a byte was copied from CHS's file — and the one field that would point back &lt;em&gt;into&lt;/em&gt; CHS's system is the one field the whole design refuses to hold.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Resolve the opaque handle at runtime, under your own licence
&lt;/h3&gt;

&lt;p&gt;The id still has to exist &lt;em&gt;somewhere&lt;/em&gt; — you need it to actually fetch current data. The move is to fetch it live, on the operator's machine, under the operator's own CHS API licence, and never write it to disk. The fitting pipeline lists CHS's stations live from the IWLS index and matches them to the registry &lt;strong&gt;by name&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// chs-constituents — the CHS id is fetched live and used only as a fetch handle&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;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="s2"&gt;wcsp1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// current 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;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="nx"&gt;operating&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="nx"&gt;operating&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;// The registry is a name overlay, NOT the id source. It reads no id at all:&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;stationsFromApi&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="nx"&gt;IwlsStation&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="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;OverlayEntry&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="k"&gt;return&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&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="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="c1"&gt;// id: live from CHS, used only to fetch, never emitted.&lt;/span&gt;
    &lt;span class="c1"&gt;// key + curated label: from our registry, when the name matches.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="p"&gt;?&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;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="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&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="p"&gt;:&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;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="na"&gt;label&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="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 join is a normalized-name match, applied symmetrically so a case/whitespace difference doesn't silently drop a station:&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;# currents-mcp — correlate a live plugin reading to a gate by name, not by id
&lt;/span&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;Fold case and trim so a label/name differing only in casing 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;span class="c1"&gt;# cache[_norm(station_label)] = events   ← keyed by name
# events_for_station(gate.name)          ← looked up by name
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CHS id lives for exactly as long as one fetch, on the operator's box, covered by the operator's own IWLS licence. It is never committed, never published, never handed to a third party. The registry, the vault, the npm package — none of them contain it.&lt;/p&gt;

&lt;p&gt;That's the entire trick: &lt;strong&gt;the facts ship; the licensed handle resolves at runtime.&lt;/strong&gt; Same functionality as the NOAA path — a consumer still gets a named station picker and offline predictions — reached by a more involved route because the licence demands it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Completeness is the &lt;em&gt;weakest&lt;/em&gt; compilation-copyright position, not the strongest.&lt;/strong&gt; Compilation copyright rewards original &lt;em&gt;selection&lt;/em&gt; — deciding what to leave out. A complete list is unselective by definition, so there's no protectable selection left. If you're mirroring a whole provider table "to be safe," you're both maximizing the terms-of-use exposure and standing on the flimsiest copyright ground. Curate a bounded, rule-governed subset instead.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The rule is about method, not about which facts appear.&lt;/strong&gt; A record that agrees with CHS on a coordinate is facts agreeing with facts. A byte-for-byte copy of CHS's export is redistributing their file. Same coordinate, different method, different answer. Write the facts yourself; don't paste their rows.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The identifier that can only come from the provider is the one to never ship.&lt;/strong&gt; A name or a position you can re-derive from a chart. An opaque internal id you cannot — its only source is the licensed API, so shipping it &lt;em&gt;is&lt;/em&gt; the redistribution. If a field has no independent origin, treat it as the provider's, and resolve it at runtime.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Contributor guidance falls straight out of the rule.&lt;/strong&gt; For anyone adding a station: obtain the name, context, and position independently (chart, gazetteer, your own fitting pipeline, direct observation) and write them yourself; &lt;strong&gt;do not paste a row out of a provider's export, and do not add a provider-id field.&lt;/strong&gt; If your workflow needs the provider's handle to join live data, resolve it there, under your own licence.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This isn't hypothetical — ask XTide.&lt;/strong&gt; XTide once shipped Canadian harmonic constants with non-commercial notices attached; Debian packaged them separately as &lt;code&gt;xtide-data-nonfree&lt;/code&gt; precisely because commercial distribution wasn't permitted. The author was then contacted by what he understood to be the Department of Justice Canada asking him to strengthen the warnings, stopped maintaining the non-US data in 2012, and &lt;a href="https://flaterco.com/xtide/faq.html" rel="noopener noreferrer"&gt;XTide has shipped US-only ever since&lt;/a&gt;. A published notice is not a substitute for a licence you were never granted. That precedent is why we ship a pipeline and a factual registry, not a dataset.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This came out of building offline current prediction for an all-electric charter catamaran working both sides of the Canada/US border, where "just bundle the tide data" is a one-liner on the US side and a licence analysis on the Canadian one. The whole pipeline is open source: the licence-clean identity layer in &lt;a href="https://github.com/sailingnaturali/station-corrections" rel="noopener noreferrer"&gt;&lt;code&gt;station-corrections&lt;/code&gt;&lt;/a&gt; (facts only, no provider ids, &lt;a href="https://github.com/sailingnaturali/station-corrections/blob/main/PROVENANCE.md" rel="noopener noreferrer"&gt;&lt;code&gt;PROVENANCE.md&lt;/code&gt;&lt;/a&gt; documenting every field), the runtime-resolution end in &lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;&lt;code&gt;chs-constituents&lt;/code&gt;&lt;/a&gt; (whose README walks the CHS licence clause by clause), and the by-name correlation in &lt;a href="https://github.com/sailingnaturali/currents-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;currents-mcp&lt;/code&gt;&lt;/a&gt;. Three stages, because the licence needs three; on the US side it would have been one.&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>licensing</category>
      <category>marine</category>
      <category>signalk</category>
    </item>
    <item>
      <title>Porting a tide engine to Swift with the original as the test oracle</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 11 Aug 2026 16:07:47 +0000</pubDate>
      <link>https://dev.to/clarkbw--/porting-a-tide-engine-to-swift-with-the-original-as-the-test-oracle-2f99</link>
      <guid>https://dev.to/clarkbw--/porting-a-tide-engine-to-swift-with-the-original-as-the-test-oracle-2f99</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Porting numeric code by hand and eyeballing the output is how you&lt;br&gt;
ship plausible-looking wrong answers. Instead, treat the original&lt;br&gt;
implementation as a &lt;em&gt;test oracle&lt;/em&gt;: scripts that run the reference and dump&lt;br&gt;
golden fixtures, one per algorithm layer, each ported layer gated on matching&lt;br&gt;
the oracle to 1e-6 before the next begins. And when the port passes, you're&lt;br&gt;
still not done — the last gate is matching &lt;em&gt;reality&lt;/em&gt;, not the reference.&lt;br&gt;
Repo: &lt;a href="https://github.com/sailingnaturali/slackwater-engine" rel="noopener noreferrer"&gt;slackwater-engine&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We needed a pure-Swift harmonic tide/current prediction engine — tide math is&lt;br&gt;
deterministic astronomy, so given a station's harmonic constituents you can&lt;br&gt;
compute heights and high/low turns for any minute, years ahead, with zero&lt;br&gt;
network. The best open reference implementation is&lt;br&gt;
&lt;a href="https://github.com/openwatersio/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt;&lt;br&gt;
(&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;, JavaScript, MIT). This post is about &lt;em&gt;how&lt;/em&gt; we ported&lt;br&gt;
it — the oracle-driven method — because the method transfers to any port of&lt;br&gt;
numeric code between languages.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: numeric ports fail silently
&lt;/h2&gt;

&lt;p&gt;The Neaps algorithm is ~600 lines of real math: astronomical mean-longitude&lt;br&gt;
polynomials, Doodson coefficients, Schureman/IHO node-correction formulas&lt;br&gt;
full of terms like this one:&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="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_nupp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="kt"&gt;N&lt;/span&gt;&lt;span class="p"&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;_&lt;/span&gt; &lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="p"&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;_&lt;/span&gt; &lt;span class="nv"&gt;omega&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;I&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d2r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;_I&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;omega&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d2r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;_nu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;omega&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;tan2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;I&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="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;sin&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="n"&gt;nu&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;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;I&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="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;cos&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="n"&gt;nu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0727&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r2d&lt;/span&gt; &lt;span class="o"&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="nf"&gt;atan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tan2&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;Flip a sign in one of those, or mix degrees and radians once, and the engine&lt;br&gt;
still produces beautiful sinusoidal tide curves — highs and lows in roughly&lt;br&gt;
the right places, just quietly off by centimetres or minutes in ways that&lt;br&gt;
drift with the 18.6-year lunar nodal cycle. Eyeballing plots against a tide&lt;br&gt;
table catches none of it. A port validated by "looks right" is a liability&lt;br&gt;
you discover at a shallow anchorage.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wire the original up as an oracle
&lt;/h2&gt;

&lt;p&gt;The fix is to make the reference implementation &lt;em&gt;generate the tests&lt;/em&gt;. A small&lt;br&gt;
Node script imports Neaps, evaluates each layer of the algorithm at fixed&lt;br&gt;
instants, and writes the results as JSON fixtures the Swift tests load:&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;// tools/gen-golden.mjs — Neaps is the oracle. TideEngine (Swift) must match.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createTidePredictor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@neaps/tide-predictor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Spread of UTC timestamps across the 18.6-year nodal cycle to exercise corrections.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2000-01-01T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2010-06-15T12:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2020-03-21T06:30:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-12T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-12T18:45:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2031-11-02T09:15:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2035-09-23T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2040-12-31T23:00:00Z&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;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astronomy.json&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;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Neaps astro(date) unwrapped to plain degrees; TideEngine must match within 1e-6.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;times&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TIMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TIMES&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;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="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;iso&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="na"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;unwrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;astro&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;t&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;Two properties make this an oracle rather than just "some tests":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fixtures are generated, never hand-written.&lt;/strong&gt; &lt;code&gt;node tools/gen-golden.mjs&lt;/code&gt;
regenerates every fixture from &lt;code&gt;@neaps/tide-predictor@0.10.0&lt;/code&gt;. There is no
opportunity to transcribe an expected value wrong, and bumping the oracle
version re-derives the whole suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's a fixture per layer, not just end-to-end.&lt;/strong&gt; An end-to-end height
comparison tells you &lt;em&gt;that&lt;/em&gt; you diverged; a per-layer fixture tells you
&lt;em&gt;where&lt;/em&gt;. Every divergence is caught at the layer that caused it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Port layer by layer, gate each layer
&lt;/h2&gt;

&lt;p&gt;The algorithm decomposes into five layers, each consuming the one below.&lt;br&gt;
Each got the same treatment: generate the fixture → write the failing Swift&lt;br&gt;
test → port the math → gate on green before starting the next layer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Golden check&lt;/th&gt;
&lt;th&gt;Tolerance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Astronomy&lt;/td&gt;
&lt;td&gt;15 quantities (mean longitudes + node angles) × 8 instants across the nodal cycle — 120 values&lt;/td&gt;
&lt;td&gt;1e-6°&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node corrections&lt;/td&gt;
&lt;td&gt;IHO f/u for 17 base constituents × 3 instants&lt;/td&gt;
&lt;td&gt;1e-6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constituents&lt;/td&gt;
&lt;td&gt;V₀ + compound f/u for ~39 constituents (incl. M4, MS4, 2MK3)&lt;/td&gt;
&lt;td&gt;1e-6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timeline prediction&lt;/td&gt;
&lt;td&gt;48 h height series, mixed-tide station, 10-min step&lt;/td&gt;
&lt;td&gt;1e-6 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extremes&lt;/td&gt;
&lt;td&gt;high/low count, kind, time, height&lt;/td&gt;
&lt;td&gt;60 s / 0.02 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because both sides implement the same algorithm, the interesting tolerance is&lt;br&gt;
&lt;em&gt;floating-point agreement&lt;/em&gt;, not "close enough for tides." The prediction test&lt;br&gt;
asserts both — the domain tolerance per point, and a divergence tripwire on&lt;br&gt;
the max error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;maxErr&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&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;err&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;got&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;maxErr&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;maxErr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="cp"&gt;#expect(err &amp;lt; 0.02, "height at \(expected.time): got \(got.height), want \(expected.height)")&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// The engines share the algorithm, so agreement should be far tighter than 2cm.&lt;/span&gt;
&lt;span class="cp"&gt;#expect(maxErr &amp;lt; 1e-6, "max height error \(maxErr) exceeds 1e-6 — engines diverging")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future change drifts the port to, say, 5 mm of error — still a perfectly&lt;br&gt;
plausible tide — the tripwire fires long before the domain tolerance would.&lt;/p&gt;

&lt;p&gt;The per-layer gates earn their keep on the fiddly semantic mismatches between&lt;br&gt;
the languages, because each one surfaces in the &lt;em&gt;one&lt;/em&gt; layer it belongs to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Timeline construction.&lt;/strong&gt; Neaps floors the start and ceils the end to the
step interval. Get that wrong and every sample is shifted — the astronomy
and constituent layers stay green, and the prediction fixture pinpoints it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Array identity vs value semantics.&lt;/strong&gt; Neaps recomputes node corrections
every 24 h and callers detect the refresh with a JS &lt;code&gt;!==&lt;/code&gt; identity check.
Swift arrays are value types — there is no identity. The port adds an
explicit &lt;code&gt;generation&lt;/code&gt; counter that callers compare instead. The extremes
fixture is what proves the refresh logic actually matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What &lt;em&gt;not&lt;/em&gt; to port.&lt;/strong&gt; Neaps ships both Schureman and IHO node-correction
schemes; the predictor only ever uses IHO. The oracle made that safe to
verify — no fixture ever exercised Schureman, so it stayed unported.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Don't port the catalog — generate it
&lt;/h2&gt;

&lt;p&gt;The single biggest porting risk wasn't the math — it was the data. Neaps&lt;br&gt;
bundles ~5,000 lines of constituent definitions: 394 constituents with&lt;br&gt;
speeds, Doodson coefficients, and compound-member decompositions, plus an&lt;br&gt;
IHO Annex-B name parser that resolves compound names at runtime. Hand-porting&lt;br&gt;
5,000 lines of numeric literals is transcription, and transcription is where&lt;br&gt;
silent errors live.&lt;/p&gt;

&lt;p&gt;So the catalog isn't code in the Swift port at all. A second codegen script&lt;br&gt;
walks Neaps' constituent map and emits it as a bundled JSON resource — with&lt;br&gt;
the name parsing &lt;em&gt;already resolved&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tools/gen-catalog.mjs — the IHO Annex-B name decomposition + sign resolution&lt;/span&gt;
&lt;span class="c1"&gt;// runs HERE, in Neaps, at build time; Swift consumes the resolved members&lt;/span&gt;
&lt;span class="c1"&gt;// and never needs the parser.&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;key&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;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constituents&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;constituents&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;c&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;aliases&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&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="c1"&gt;// NOAA "NU2" → canonical "nu2"&lt;/span&gt;
  &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&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="nx"&gt;name&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="nx"&gt;c&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="na"&gt;speed&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="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;coefficients&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="nx"&gt;coefficients&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="c1"&gt;// Doodson, when fundamental&lt;/span&gt;
    &lt;span class="na"&gt;members&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="nx"&gt;members&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;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituent&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="na"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;factor&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;A compound constituent in the emitted &lt;code&gt;catalog.json&lt;/code&gt; (52 KB, 394 constituents,&lt;br&gt;
83 name aliases) looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2MK3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;42.9271398&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"coefficients"&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"members"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"M2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"K1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-1&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;And the entire Swift side of "compound constituent support" collapses to two&lt;br&gt;
recursions over pre-resolved members:&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;/// Node correction (f, u°). IHO fundamental if the constituent has one, else&lt;/span&gt;
&lt;span class="c1"&gt;/// combined from members: f = Π f_memberᵃᵇˢ⁽ᶠᵃᶜᵗᵒʳ⁾, u = Σ factor·u_member.&lt;/span&gt;
&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;correction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Astro&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="nv"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonical&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="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;fundamental&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ihoCorrection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fundamental&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;byName&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;return&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&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;m&lt;/span&gt; &lt;span class="k"&gt;in&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;members&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;correction&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;name&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="n"&gt;u&lt;/span&gt; &lt;span class="o"&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;factor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;f&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;factor&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&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 aliases matter more than they look: real station data is inconsistent&lt;br&gt;
about constituent names (NOAA publishes &lt;code&gt;NU2&lt;/code&gt;, &lt;code&gt;MM&lt;/code&gt;, &lt;code&gt;RHO&lt;/code&gt;; the catalog's&lt;br&gt;
canonical names are &lt;code&gt;nu2&lt;/code&gt;, &lt;code&gt;Mm&lt;/code&gt;, &lt;code&gt;rho1&lt;/code&gt;), and the engine silently ignores&lt;br&gt;
constituents it can't resolve. Without alias resolution, published harmonic&lt;br&gt;
constants would predict — just with pieces of the tide missing.&lt;/p&gt;

&lt;p&gt;Net result: the whole engine is &lt;strong&gt;577 lines of Swift&lt;/strong&gt; against Neaps'&lt;br&gt;
~6,300-line bundle, because the 5,000 data lines crossed over as data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Golden checks → &lt;code&gt;swift test&lt;/code&gt; → CI
&lt;/h2&gt;

&lt;p&gt;The first iterations ran as a throwaway command-line checker. Once the layers&lt;br&gt;
were green, the checks converted to &lt;a href="https://github.com/swiftlang/swift-testing" rel="noopener noreferrer"&gt;swift-testing&lt;/a&gt;&lt;br&gt;
suites — fixtures under &lt;code&gt;Tests/TideEngineTests/Fixtures/&lt;/code&gt; — so the whole&lt;br&gt;
oracle runs as plain &lt;code&gt;swift test&lt;/code&gt;, and CI is 18 lines:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&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;swift test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;macos-15&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Run tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;swift test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That conversion is worth calling out as its own step: an oracle that lives in&lt;br&gt;
a side script rots; an oracle that &lt;em&gt;is&lt;/em&gt; the test suite re-verifies the entire&lt;br&gt;
port on every push, forever.&lt;/p&gt;
&lt;h2&gt;
  
  
  Matching the reference isn't the finish line
&lt;/h2&gt;

&lt;p&gt;At this point the Swift engine reproduces Neaps to floating-point agreement.&lt;br&gt;
That proves the &lt;em&gt;port&lt;/em&gt; is faithful. It proves nothing about whether the&lt;br&gt;
&lt;em&gt;algorithm&lt;/em&gt; predicts actual water — if the reference had a bug, we ported the&lt;br&gt;
bug at 1e-6 fidelity.&lt;/p&gt;

&lt;p&gt;So the final gate compares against the tide authority itself. A third codegen&lt;br&gt;
script takes published harmonic constants for Friday Harbor, WA&lt;br&gt;
(NOAA 9449880, via &lt;code&gt;@neaps/tide-database&lt;/code&gt;) and fetches NOAA's own official&lt;br&gt;
high/low predictions from the CO-OPS API for the same window:&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;fh&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;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noaa/9449880&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;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MSL&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;datums&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MLLW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shift MSL-relative harmonics to chart datum&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?...`&lt;/span&gt;
  &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s2"&gt;`&amp;amp;station=9449880&amp;amp;product=predictions&amp;amp;datum=MLLW&amp;amp;interval=hilo&amp;amp;units=metric&amp;amp;time_zone=gmt`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test predicts extremes from the constants and matches each official&lt;br&gt;
high/low to the nearest computed one of the same kind — with deliberately&lt;br&gt;
&lt;em&gt;looser&lt;/em&gt; tolerances than the oracle tests, because now the differences are&lt;br&gt;
real (datum rounding, node-correction epoch, constituent-set differences&lt;br&gt;
between NOAA's internal engine and ours):&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="cp"&gt;#expect(timeErr &amp;lt; 15, "\(off.kind) at \(off.time): time off by \(timeErr) min")&lt;/span&gt;
&lt;span class="cp"&gt;#expect(heightErr &amp;lt; 0.15, "\(off.kind) at \(off.time): height off \(heightErr) m")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result across 12 highs/lows over three days: &lt;strong&gt;max time error 7.9 minutes,&lt;br&gt;
max height error 3.5 cm&lt;/strong&gt; — well inside tide-table tolerance. The ~8-minute&lt;br&gt;
residual is expected and instructive: NOAA's engine uses a different&lt;br&gt;
node-correction epoch and constituent set, so &lt;em&gt;exact&lt;/em&gt; agreement with the&lt;br&gt;
authority was never on the table. Two tolerance regimes, two different&lt;br&gt;
claims: 1e-6 says "the port is faithful"; ±15 min/±0.15 m says "the&lt;br&gt;
predictions are real."&lt;/p&gt;

&lt;p&gt;The same validation pass confirmed the data path for Canadian waters:&lt;br&gt;
CHS doesn't publish bundled harmonics the way NOAA does, but its IWLS API&lt;br&gt;
serves tide &lt;em&gt;predictions&lt;/em&gt; directly — and current predictions for 35+ BC&lt;br&gt;
stations — so Canadian coverage rides the authority's own numbers online&lt;br&gt;
rather than redistributed constituents.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trim the API before anyone depends on it
&lt;/h2&gt;

&lt;p&gt;Porting layer-by-layer leaves scaffolding: everything was &lt;code&gt;public&lt;/code&gt; so the&lt;br&gt;
interim checker could poke at it, and some ported code turned out dead (an&lt;br&gt;
astronomical-speeds derivation the engine never uses — constituent speeds&lt;br&gt;
ship pre-computed in the catalog). The last commit of the phase is a&lt;br&gt;
deliberate audit: delete the dead code, demote the internals, and ship the&lt;br&gt;
smallest surface that does the job —&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="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;Station&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;HarmonicConstituent&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;heights&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&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="kt"&gt;Date&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="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&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="kt"&gt;TidePoint&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;extremes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&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="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="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;TideExtreme&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;Four public types, two methods. &lt;code&gt;Astro&lt;/code&gt;, the catalog, the node-correction&lt;br&gt;
table — all internal. Every symbol you expose during the port is a symbol&lt;br&gt;
you can't rename after someone imports it; the oracle makes the trim safe,&lt;br&gt;
because &lt;code&gt;swift test&lt;/code&gt; proves the demotions broke nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three transferable lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An oracle turns a risky port into a mechanical one.&lt;/strong&gt; Generate golden
vectors from the reference per layer, and every divergence is caught at
the layer that caused it — sign flips, degree/radian mixups, and
value-vs-reference semantic mismatches all surfaced as a single red layer,
never as "the tides look slightly wrong."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate data catalogs from the source; only port algorithms.&lt;/strong&gt; The
5,000-line constituent catalog crossed languages as a JSON resource with
the hard parts (name parsing, compound decomposition) pre-resolved at
codegen time. Data you generate can't be mistranscribed, and regenerating
beats re-porting when upstream moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching the reference isn't the finish line — matching reality is.&lt;/strong&gt;
A faithful port of a wrong reference is still wrong. Close the loop
against the authority's published numbers, with honest (looser) tolerances
and an explanation for the residual.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The engine is MIT, extracted as the open-source core for offline tide work on&lt;br&gt;
our own boats: &lt;a href="https://github.com/sailingnaturali/slackwater-engine" rel="noopener noreferrer"&gt;slackwater-engine&lt;/a&gt;.&lt;br&gt;
If your home waters disagree with it, the fixtures show you exactly which&lt;br&gt;
layer to blame — send a fix.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &lt;a href="https://dev.to%20post_url%202026-06-10-output-blocked-by-content-filtering-policy-verbatim-transcription-deterministic-extraction%20"&gt;Why Claude won't transcribe your PDF — and what to do instead&lt;/a&gt; · &lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;Why generic weather MCPs fail for marine navigation (use NDBC buoys)&lt;/a&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>testing</category>
      <category>tides</category>
      <category>marine</category>
    </item>
    <item>
      <title>Bench your own workload before you switch LLM vendors</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:17:57 +0000</pubDate>
      <link>https://dev.to/clarkbw--/bench-your-own-workload-before-you-switch-llm-vendors-3b43</link>
      <guid>https://dev.to/clarkbw--/bench-your-own-workload-before-you-switch-llm-vendors-3b43</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Before migrating our voice boat-agent off Claude Sonnet, we ran the&lt;br&gt;
new GPT-5.6 tiers through the same tool-routing benchmark the incumbent runs:&lt;br&gt;
13 real asks, 74 MCP tool schemas, scored on whether the model calls the right&lt;br&gt;
tools. GPT-5.6 scored 38.5% vs Sonnet's 92.3% &lt;em&gt;on our workload, under our&lt;br&gt;
integration constraints&lt;/em&gt; — so no migration. Jump to the numbers.&lt;br&gt;
The transferable part isn't the score, it's the method.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What this is not — read this before the numbers.&lt;/strong&gt; This is &lt;em&gt;not&lt;/em&gt; a model&lt;br&gt;
benchmark, and it says nothing about GPT-5.6's overall quality. It is a narrow,&lt;br&gt;
specific fit-for-workload test: can a candidate model, dropped into &lt;em&gt;our&lt;/em&gt;&lt;br&gt;
integration path (an OpenAI-compatible &lt;code&gt;/v1/chat/completions&lt;/code&gt; runner, which&lt;br&gt;
forces &lt;code&gt;reasoning_effort: "none"&lt;/code&gt; when tools are present — more on that below),&lt;br&gt;
route a voice agent's asks to the right tools across &lt;em&gt;our&lt;/em&gt; 74-tool MCP surface?&lt;br&gt;
GPT-5.6's full tool-calling story lives in the Responses API, which our runner&lt;br&gt;
doesn't speak. The result generalizes to nothing beyond "not a drop-in swap for&lt;br&gt;
this system, today." If you take a headline score away from this post, you've&lt;br&gt;
misread it. Take the method.&lt;/p&gt;
&lt;h2&gt;
  
  
  The temptation
&lt;/h2&gt;

&lt;p&gt;Every time a new flagship model ships, the same question shows up: &lt;em&gt;should we&lt;br&gt;
migrate?&lt;/em&gt; The leaderboards look great. The pricing looks great. The blog posts&lt;br&gt;
are glowing.&lt;/p&gt;

&lt;p&gt;None of that measures your workload.&lt;/p&gt;

&lt;p&gt;Our workload is a voice agent on a boat. The architecture pushes determinism&lt;br&gt;
into code — spoken phrasing is templates, tool results are formatted in the&lt;br&gt;
tool layer, composition happens in composed tools. The LLM's actual job is&lt;br&gt;
narrow: &lt;strong&gt;hear an ask, pick the right MCP tool(s), call them with workable&lt;br&gt;
args.&lt;/strong&gt; "What's the current at Boundary Pass?" must route to&lt;br&gt;
&lt;code&gt;currents.get_gate_current&lt;/code&gt;, not to a generic sensor read. That's it. Peak&lt;br&gt;
reasoning is not on the critical path; tool-routing correctness and latency&lt;br&gt;
are.&lt;/p&gt;

&lt;p&gt;No public leaderboard scores "routes correctly across our 74 marine MCP tool&lt;br&gt;
schemas." So we built the bench once, and now every candidate model gets the&lt;br&gt;
same 15-minute test before any migration conversation is allowed to continue.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision rule
&lt;/h2&gt;

&lt;p&gt;The swap rule is written down as code, not vibes. A candidate replaces the&lt;br&gt;
incumbent only if it's faster &lt;em&gt;and&lt;/em&gt; holds correctness within tolerance &lt;em&gt;and&lt;/em&gt; is&lt;br&gt;
stable:&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;compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;incumbent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Scorecard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Scorecard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&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;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_rate&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate not session-stable &lt;/span&gt;&lt;span class="sh"&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;(error_rate=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error_rate&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency_p50&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;incumbent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency_p50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate not faster &lt;/span&gt;&lt;span class="sh"&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;(p50 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency_p50&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s vs &lt;/span&gt;&lt;span class="sh"&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;incumbent &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;incumbent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;latency_p50&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s)&lt;/span&gt;&lt;span class="sh"&gt;"&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;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correctness&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;incumbent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correctness&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate correctness below tolerance &lt;/span&gt;&lt;span class="sh"&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;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correctness&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &amp;lt; &lt;/span&gt;&lt;span class="sh"&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;incumbent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correctness&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;eps&lt;/span&gt;&lt;span class="si"&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what this optimizes: &lt;strong&gt;speed at a correctness bar&lt;/strong&gt;, not peak capability.&lt;br&gt;
We &lt;em&gt;want&lt;/em&gt; to be persuaded by a faster model. The voice loop has a &amp;lt;5 s warm-hop&lt;br&gt;
target; the incumbent's p50 is over 8 s. A candidate that's 3× faster and&lt;br&gt;
routes tools correctly wins the slot immediately. The bar is the incumbent's&lt;br&gt;
correctness minus ε, because prose quality is deterministic downstream — the&lt;br&gt;
model only has to string the right tools.&lt;/p&gt;
&lt;h2&gt;
  
  
  The harness
&lt;/h2&gt;

&lt;p&gt;The bench (&lt;code&gt;python -m poseidon.bench&lt;/code&gt; in&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;naturali-agents&lt;/a&gt;) drives a&lt;br&gt;
golden set of 13 asks, each annotated with the expected tool call(s):&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;"current-boundary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navigator"&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;"What's the current doing at Boundary Pass?"&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__currents__get_gate_current"&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="err"&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;"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;"safe-to-anchor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navigator-multi"&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;"Is it safe to anchor here tonight given the weather and current?"&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__pilotbook__assess_anchorage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The incumbent runs through its production backend (the Claude Agent SDK). Any&lt;br&gt;
candidate runs through a flat OpenAI-compatible runner — same asks, same 74&lt;br&gt;
tool schemas exported from the production MCP server config, same&lt;br&gt;
recall-based scoring (did the right tools get called; extra exploratory calls&lt;br&gt;
don't penalize):&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;# incumbent baseline (SDK backend)&lt;/span&gt;
uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; poseidon.bench &lt;span class="nt"&gt;--model&lt;/span&gt; claude-sonnet-4-6

&lt;span class="c"&gt;# candidate (any OpenAI-compatible endpoint: hosted or local Ollama)&lt;/span&gt;
uv run python &lt;span class="nt"&gt;-m&lt;/span&gt; poseidon.bench &lt;span class="nt"&gt;--backend&lt;/span&gt; openai &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--base-url&lt;/span&gt; https://api.openai.com/v1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; gpt-5.6-terra &lt;span class="nt"&gt;--reasoning-effort&lt;/span&gt; none &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--baseline&lt;/span&gt; dev/bench-results/2026-07-12-claude-sonnet-4-6.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--baseline&lt;/code&gt; flag makes the run print the swap verdict directly. One&lt;br&gt;
command, a scorecard, an answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the bench surfaced before it produced a number
&lt;/h2&gt;

&lt;p&gt;The runner was originally built for local Ollama models. Pointing it at a&lt;br&gt;
hosted endpoint broke twice — and both breaks are exactly the kind of&lt;br&gt;
integration detail a real bench surfaces and a leaderboard never will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Hosted endpoints need auth; Ollama had let us be lazy.&lt;/strong&gt; First run: HTTP&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ollama ignores the &lt;code&gt;Authorization&lt;/code&gt; header entirely, so the harness had
never sent one. The fix is small and keeps local runs working:
&lt;/li&gt;
&lt;/ol&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;auth_headers&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="nb"&gt;str&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Bearer auth for hosted OpenAI-compatible endpoints; empty for local
    (Ollama ignores auth).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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;OPENAI_API_KEY&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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;else&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;2. GPT-5.6 rejects function tools on &lt;code&gt;/v1/chat/completions&lt;/code&gt; unless&lt;br&gt;
&lt;code&gt;reasoning_effort&lt;/code&gt; is &lt;code&gt;"none"&lt;/code&gt;.&lt;/strong&gt; With auth fixed, requests carrying our&lt;br&gt;
&lt;code&gt;tools&lt;/code&gt; array were rejected until we sent &lt;code&gt;reasoning_effort: "none"&lt;/code&gt;. Full&lt;br&gt;
reasoning &lt;em&gt;plus&lt;/em&gt; tools is a Responses-API feature; on the Chat Completions&lt;br&gt;
surface you pick one. So the payload builder grew a passthrough:&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;chat_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schemas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasoning_effort&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;reasoning_effort is passed only when set — GPT-5.6+ rejects function
    tools unless it&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; (full reasoning + tools needs the Responses
    API, which this runner predates).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schemas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_choice&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;auto&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;stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;reasoning_effort&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning_effort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reasoning_effort&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second fix matters for reading the results honestly: it means every&lt;br&gt;
GPT-5.6 number below was produced &lt;strong&gt;with reasoning off&lt;/strong&gt;, because that's the&lt;br&gt;
only mode the OpenAI-compatible surface we integrate against permits with&lt;br&gt;
tools. It's a real constraint of the drop-in path we'd actually deploy — the&lt;br&gt;
same seam a local model or any OpenAI-compatible engine would use — but it is&lt;br&gt;
a constraint, and we recorded it as such.&lt;/p&gt;
&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Same day, same 13-ask golden set, same live MCP stack, all three GPT-5.6 tiers&lt;br&gt;
plus a fresh incumbent baseline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Correctness&lt;/th&gt;
&lt;th&gt;Error rate&lt;/th&gt;
&lt;th&gt;p50 warm hop&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;Swap verdict (ε=0.05)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;claude-sonnet-4-6 (incumbent)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92.3%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;8.62 s&lt;/td&gt;
&lt;td&gt;18.23 s&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-sol (reasoning none)&lt;/td&gt;
&lt;td&gt;38.5%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.40 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9.64 s&lt;/td&gt;
&lt;td&gt;SWAP=False&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-terra (reasoning none)&lt;/td&gt;
&lt;td&gt;38.5%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.32 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.35 s&lt;/td&gt;
&lt;td&gt;SWAP=False&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-5.6-luna (reasoning none)&lt;/td&gt;
&lt;td&gt;46.2%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.79 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.99 s&lt;/td&gt;
&lt;td&gt;SWAP=False&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things are true at once here, and both matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency is excellent.&lt;/strong&gt; Every GPT-5.6 tier demolishes our &amp;lt;5 s voice-loop&lt;br&gt;
target that the incumbent misses. Terra's p50 of 2.32 s is the fastest&lt;br&gt;
tool-calling turn we've measured on this workload, cloud or local. If&lt;br&gt;
correctness had held, this would have been an easy swap — that's what the&lt;br&gt;
decision rule is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Routing collapsed.&lt;/strong&gt; The failure isn't a near-miss on a couple of hard asks;&lt;br&gt;
it's qualitative. With reasoning off, all three tiers reach for the generic&lt;br&gt;
&lt;code&gt;mcp__signalk__read_sensor&lt;/code&gt; on nearly everything. From the Terra scorecard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| ask             | expected                              | observed                    |   |
|-----------------|---------------------------------------|-----------------------------|---|
| wind-forecast   | mcp__weather__get_marine_forecast     | mcp__signalk__read_sensor   | ✗ |
| currents-nearby | mcp__currents__currents_near          | mcp__signalk__read_sensor   | ✗ |
| tide-heights    | mcp__currents__get_tide_heights       | mcp__signalk__read_sensor   | ✗ |
| anchorage-near  | mcp__pilotbook__find_anchorages_near  | mcp__signalk__read_sensor   | ✗ |
| safe-to-anchor  | mcp__pilotbook__assess_anchorage      | mcp__signalk__read_sensor   | ✗ |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A wind &lt;em&gt;forecast&lt;/em&gt; question answered from a live sensor read. An anchorage&lt;br&gt;
question answered from a sensor read. Eight of thirteen asks on Terra route to&lt;br&gt;
the wrong subsystem the same way; Sol and Luna show the same shape. Direct&lt;br&gt;
single-tool asks ("what's my depth") still land — it's the &lt;em&gt;routing across a&lt;br&gt;
wide tool surface&lt;/em&gt; that goes.&lt;/p&gt;

&lt;p&gt;We'd seen this exact failure shape before, in small local models — the&lt;br&gt;
generic-over-specific tool grab is what an 8B does under pressure. Seeing it&lt;br&gt;
here says something useful and narrow: &lt;strong&gt;whatever makes a model good at&lt;br&gt;
picking one tool out of 74 was, on this surface and in this mode, doing that&lt;br&gt;
work in the part we had to turn off.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fairness caveat, again
&lt;/h2&gt;

&lt;p&gt;Worth restating at the point of maximum temptation to over-read: the&lt;br&gt;
candidates ran under the constraint the OpenAI-compatible surface imposes&lt;br&gt;
(tools ⇒ reasoning none). GPT-5.6's designed tool-calling path — reasoning&lt;br&gt;
plus tool use — lives in the Responses API, which our runner, and any&lt;br&gt;
drop-in OpenAI-compatible engine seam like ours, doesn't speak. A fair&lt;br&gt;
re-test requires porting the runner to the Responses API. That's real work&lt;br&gt;
with no current payoff, so we recorded it as the &lt;em&gt;retest condition&lt;/em&gt; instead of&lt;br&gt;
doing it: if the runner grows a Responses backend, or an OpenAI-compatible&lt;br&gt;
endpoint ships that allows tools with reasoning on, GPT-5.6 gets re-benched.&lt;/p&gt;

&lt;p&gt;That's also the honest framing of the whole result: &lt;strong&gt;we didn't measure what&lt;br&gt;
GPT-5.6 can do; we measured what it does when dropped into our seam.&lt;/strong&gt; For a&lt;br&gt;
migration decision, that's the measurement that matters — you migrate onto&lt;br&gt;
your integration path, not onto the vendor's best-case demo path.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The bar is yours, not the leaderboard's.&lt;/strong&gt; Our agent doesn't need chart-top
reasoning; it needs to pick &lt;code&gt;get_tide_heights&lt;/code&gt; over &lt;code&gt;read_sensor&lt;/code&gt; at 2 a.m.
in a voice loop. No public eval scores that. Yours scores whatever &lt;em&gt;your&lt;/em&gt;
system actually spends the model on — write that down as a golden set and a
decision rule while nobody's pressuring you to migrate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bench is cheap once it exists.&lt;/strong&gt; This entire gate — three models,
fresh baseline, scorecards, verdicts — was about 15 minutes of wall time and
negligible API spend. The two harness fixes above were most of the effort,
and they're paid for now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bench finds integration truth before migration finds it for you.&lt;/strong&gt; The
Bearer-auth gap and the &lt;code&gt;reasoning_effort&lt;/code&gt; constraint surfaced in a
15-minute bench. The alternative was discovering them mid-migration, after
the decision was already emotionally made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Negative results are a deliverable.&lt;/strong&gt; The verdict lives in a dated
scorecard and an architecture decision record. Next time a model ships, the
conversation starts from "run the bench," not from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The harness, golden set, and swap rule are in&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;naturali-agents&lt;/a&gt; —&lt;br&gt;
&lt;code&gt;poseidon/bench/&lt;/code&gt;. This came out of building the AI ops layer for an&lt;br&gt;
all-electric charter catamaran, where the conversation engine is a pluggable&lt;br&gt;
part and the tool surface is the product.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-discrete-tools-vs-execute-code-mcp-for-voice-agents%20"&gt;Discrete MCP tools vs execute_code: when each wins&lt;/a&gt; ·&lt;br&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;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>mcp</category>
      <category>tooluse</category>
    </item>
    <item>
      <title>The NMEA 2000 paradox has an open-source answer</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:57:31 +0000</pubDate>
      <link>https://dev.to/clarkbw--/the-nmea-2000-paradox-has-an-open-source-answer-1j9c</link>
      <guid>https://dev.to/clarkbw--/the-nmea-2000-paradox-has-an-open-source-answer-1j9c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Galvanic Works published &lt;a href="https://galvanicworks.com/blog/the-nmea-2000-paradox" rel="noopener noreferrer"&gt;The NMEA 2000 Paradox&lt;/a&gt; — a sharp problem statement about why boats drowning in data still fail their owners: identical beeps for everything, humans as the monitoring system, alerts that collapse under stress, and vendor lock-in. I agree with essentially all of it. This post is the other half: each failure mode they name, next to the open-source code that already addresses it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Galvanic Works is a marine-safety startup, and their post is the best short statement of the problem I've seen from anyone, commercial or open source. The line that should be framed on every marine electronics bench:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The boat is sinking: beep-beep. The waste tank is full: beep-beep.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Their catalog of failure modes: &lt;strong&gt;identical alerts&lt;/strong&gt; train sailors to ignore alarms; &lt;strong&gt;humans can't monitor dozens of simultaneous data streams&lt;/strong&gt;, least of all under stress; alerts aren't &lt;strong&gt;plain language&lt;/strong&gt; when it matters; and &lt;strong&gt;proprietary gateways and manufacturer apps&lt;/strong&gt; keep the data locked up. They're building a commercial product around fixing this. I've spent the season building the open-source version — a &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt;-based stack with an AI agent on top — and their post reads like my design doc's problem section. So, receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identical beeps: severity is data, not a sound
&lt;/h2&gt;

&lt;p&gt;The beep-beep problem isn't a hardware limitation — it's a data-model failure, and SignalK already fixed the model. Every SignalK notification carries an explicit severity &lt;code&gt;state&lt;/code&gt; and delivery &lt;code&gt;method&lt;/code&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;"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;"notifications.mob"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&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;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"emergency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"method"&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;"visual"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sound"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Man overboard!"&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;&lt;code&gt;normal&lt;/code&gt; → &lt;code&gt;alert&lt;/code&gt; → &lt;code&gt;warn&lt;/code&gt; → &lt;code&gt;alarm&lt;/code&gt; → &lt;code&gt;emergency&lt;/code&gt;. Once severity is data, nothing downstream ever has to emit the same beep twice. My &lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;signalk-ntfy-relay&lt;/a&gt; maps it straight onto &lt;a href="https://ntfy.sh" rel="noopener noreferrer"&gt;ntfy&lt;/a&gt; push priority, so a sinking boat interrupts your phone's do-not-disturb and a full waste tank does not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal K state&lt;/th&gt;
&lt;th&gt;ntfy priority&lt;/th&gt;
&lt;th&gt;tag&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;emergency&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 (max)&lt;/td&gt;
&lt;td&gt;🆘 &lt;code&gt;sos&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alarm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4 (high)&lt;/td&gt;
&lt;td&gt;🚨 &lt;code&gt;rotating_light&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;warn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 (default)&lt;/td&gt;
&lt;td&gt;⚠️ &lt;code&gt;warning&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 (low)&lt;/td&gt;
&lt;td&gt;ℹ️ &lt;code&gt;information_source&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cleared (&lt;code&gt;normal&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1 (min)&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;white_check_mark&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details matter as much as the mapping. The relay &lt;strong&gt;edge-triggers&lt;/strong&gt; — one push when an alarm becomes active, not a repeat while it persists (repetition is how you train people to ignore alarms). And &lt;strong&gt;clearing is a first-class event&lt;/strong&gt; (&lt;code&gt;notifyOnClear&lt;/code&gt;), because "the alarm stopped" is information too, and a system that only ever escalates erodes trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  You are not the monitoring system
&lt;/h2&gt;

&lt;p&gt;Their sharpest observation: humans can't watch dozens of streams, and "during an emergency, our brains become 10 times smaller." Agreed — so don't make the human the monitor. My watchstander is an AI agent with two MCP tools in a loop: &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&gt;'s &lt;code&gt;get_active_alarms&lt;/code&gt; pulls every live notification off the bus, and &lt;a href="https://github.com/sailingnaturali/vessel-knowledge-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;vessel-knowledge-mcp&lt;/code&gt;&lt;/a&gt;'s &lt;code&gt;explain_notification&lt;/code&gt; joins each one against the vessel's own equipment registry — so the agent doesn't just relay &lt;code&gt;notifications.electrical.batteries.house.voltage&lt;/code&gt;, it knows &lt;em&gt;which&lt;/em&gt; battery bank, what chemistry, what the manufacturer's floor is, and what to do about it.&lt;/p&gt;

&lt;p&gt;The alarms themselves are plain SignalK &lt;strong&gt;zones&lt;/strong&gt; — declarative thresholds in any path's metadata, set from the admin UI, no code:&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;"electrical.batteries.house.voltage"&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;"meta"&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;"zones"&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;"lower"&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.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;"upper"&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.4&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"message"&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 bank low"&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;"upper"&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.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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alarm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&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 bank critically low"&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;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 job starts where the zone ends: triage, explanation, and a spoken sentence — not another beep.&lt;/p&gt;

&lt;p&gt;One lesson I learned the hard way belongs in any writeup of this problem: the alarm pipeline itself is a thing that fails, silently, precisely because a healthy boat fires no alarms. I wrote that one up separately — the relay now heartbeats its own delivery path and raises &lt;code&gt;notifications.ntfyRelay.deliveryFailed&lt;/code&gt; when the path to your phone goes dark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plain language under stress
&lt;/h2&gt;

&lt;p&gt;The stress-communication point is where most builders get it wrong by adding detail. The discipline that works is subtraction. My distress plugins (&lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;signalk-dsc&lt;/a&gt;, &lt;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;signalk-ais-distress&lt;/a&gt;) render a &lt;strong&gt;voice-sized message&lt;/strong&gt; — type, vessel, situation, range and direction from own position, action, and nothing else:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DSC distress alert: vessel Wind Chaser, sinking, 2.3 nautical miles northwest. Monitor channel 16.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No MMSI read aloud. No raw coordinates. No "kn" — units are spoken in full because a stressed human parsing text-to-speech shouldn't decode abbreviations. The full detail (MMSI, coordinates, reported time, transport) goes to the call log and an automatic logbook entry, where detail belongs. The agent's own persona rules enforce the same budget conversationally: three sentences is the norm, because every reply is spoken aloud.&lt;/p&gt;

&lt;p&gt;The test for any alert is whether it works for a tired human at 0300. Everything that fails that test still gets captured — just not spoken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lock-in: integrate, don't replace
&lt;/h2&gt;

&lt;p&gt;Their fourth complaint — expensive gateways, manufacturer-specific apps, PGN translation configured per vendor — is the one the open-source world solved most thoroughly, because it's the reason SignalK exists. The server sits &lt;strong&gt;beside&lt;/strong&gt; the existing electronics as one more bus citizen: NMEA 2000 and 0183 in, one open JSON data model out, every vendor's PGNs normalized to the same paths. Nothing gets replaced; the chartplotter keeps working; the data stops being captive.&lt;/p&gt;

&lt;p&gt;To be fair to the hardware reality: you still need a physical bus interface (a CAN adapter or Pi HAT, tens of dollars, not hundreds) — the open stack removes the &lt;em&gt;software&lt;/em&gt; toll, which is where the lock-in actually lives. Every piece named in this post is MIT-licensed and on npm under &lt;a href="https://www.npmjs.com/org/sailingnaturali" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali&lt;/code&gt;&lt;/a&gt;; the alarm model and zones are stock SignalK.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap they're right about
&lt;/h2&gt;

&lt;p&gt;One item in their post names something the open stack genuinely doesn't have: &lt;strong&gt;trend analytics over accumulated history&lt;/strong&gt; — battery degradation curves, maintenance forecasting from engine metrics. The storage half exists (SignalK-to-InfluxDB is mature), but the analysis layer — "your house bank has lost 11% capacity since spring, book a load test" — is unbuilt in the open as far as I can tell. That's a real gap, it's the kind of thing a commercial player may well do first, and I'd love to be wrong about nobody having built it. If you have, say so.&lt;/p&gt;

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

&lt;p&gt;Full disclosure: my boat is ashore, and most of what this server reports is a mocked vessel on a bench.&lt;/p&gt;

&lt;p&gt;The RF half, though, stopped being hypothetical while this post sat in draft. A VHF receiver went onto the bench rig in late July, and real AIS is now flowing into SignalK from it — live targets, real MMSIs, actual traffic moving around the harbour. (The first read was structured garbage. The RS422 differential pair was reversed; the fix was swapping two wires, after a couple of hours of blaming the baud rate.) Position still comes from the mock, because a radio indoors has no sky view, and the DSC watch hasn't had its live soak test yet. So the alarm plumbing above is working code against real radio traffic — and still not sea miles.&lt;/p&gt;

&lt;p&gt;But that's rather the point of doing it in the open. Galvanic Works wrote an excellent diagnosis and is building their answer as a product; I'm building mine as a pile of MIT-licensed SignalK plugins and MCP servers anyone can install tonight. Boats get safer both ways, and the more people naming the beep-beep problem out loud, the fewer identical beeps get shipped. Diagnosis fully endorsed — here's the source.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; &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; · &lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;DSC distress calls into SignalK&lt;/a&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>nmea2000</category>
      <category>marine</category>
      <category>alarms</category>
    </item>
    <item>
      <title>MCP server or a curl recipe in AGENTS.md? Measure the breakeven</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 02 Aug 2026 15:30:03 +0000</pubDate>
      <link>https://dev.to/clarkbw--/mcp-server-or-a-curl-recipe-in-agentsmd-measure-the-breakeven-17k0</link>
      <guid>https://dev.to/clarkbw--/mcp-server-or-a-curl-recipe-in-agentsmd-measure-the-breakeven-17k0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The current consensus is that MCP is a token tax and you should just tell the agent to &lt;code&gt;curl&lt;/code&gt; the API from its &lt;code&gt;AGENTS.md&lt;/code&gt;. I measured both, for the same capability, against the same server. The tool schemas cost &lt;strong&gt;+408 tokens standing&lt;/strong&gt; and save &lt;strong&gt;~340–420 tokens per read&lt;/strong&gt;, so the breakeven is roughly &lt;strong&gt;one tool call per turn&lt;/strong&gt; — and with prompt caching (measured, not assumed: a 0.175× effective multiplier across 3,782 real requests) it moves to &lt;strong&gt;one read every five or six turns&lt;/strong&gt;. But the tokens are the easy half and they nearly cancel. What actually decided it was a minus sign. Jump to the breakeven formula.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a good argument going around that MCP servers are a context-window tax — &lt;a href="https://layered.dev/mcp-tool-schema-bloat-the-hidden-token-tax-and-how-to-fix-it/" rel="noopener noreferrer"&gt;tool schemas at ~550–1,400 tokens each&lt;/a&gt;, &lt;a href="https://www.firecrawl.dev/blog/mcp-vs-cli" rel="noopener noreferrer"&gt;benchmarks putting MCP at 4–32× the token cost of an equivalent CLI&lt;/a&gt;, a &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2808" rel="noopener noreferrer"&gt;spec issue open on the overhead itself&lt;/a&gt;. The conclusion people draw is: skip the server, describe the API in your agent file, let the model shell out.&lt;/p&gt;

&lt;p&gt;I have the same capability implemented both ways, so I measured it instead of arguing about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to let an agent read a boat
&lt;/h2&gt;

&lt;p&gt;The job: an agent that can answer "how much water under the keel?" and "how's the house bank?" from a &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt; server — the open marine data server that normalises NMEA 2000/0183 into one JSON tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — an MCP server.&lt;/strong&gt; &lt;code&gt;signalk-mcp&lt;/code&gt; exposes seven named tools. The schemas sit in the prompt every turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&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;Use this for &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s our depth?&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="s"&gt;how much under the keel?&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="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;how close are we to running aground?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; — returns water depth with &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;under-keel clearance first. below_keel_m IS the clearance under the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hull (no draft math needed). Do NOT read depth via read_sensor: the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw transducer path (environment.depth.belowTransducer) is NOT &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;under-keel depth and will mislead. Do not guess depth paths or &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compute clearance yourself; call this.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;inputSchema&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;type&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;object&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;properties&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;Seven of those: &lt;code&gt;get_active_alarms&lt;/code&gt;, &lt;code&gt;read_sensor&lt;/code&gt;, &lt;code&gt;get_route&lt;/code&gt;, &lt;code&gt;battery_state&lt;/code&gt;, &lt;code&gt;depth_state&lt;/code&gt;, &lt;code&gt;get_local_time&lt;/code&gt;, &lt;code&gt;list_paths&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — a prose recipe in the agent file.&lt;/strong&gt; No server, no schemas. The agent gets shell access and a section in its always-on &lt;code&gt;AGENTS.md&lt;/code&gt;:&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="gu"&gt;## Reading the boat (SignalK)&lt;/span&gt;

Boat data lives in SignalK on &lt;span class="sb"&gt;`http://localhost:3000`&lt;/span&gt; (anonymous reads, no auth).
Use the exec tool with &lt;span class="sb"&gt;`curl`&lt;/span&gt;. &lt;span class="sb"&gt;`jq`&lt;/span&gt; is available for filtering.
&lt;span class="p"&gt;
-&lt;/span&gt; One value: &lt;span class="sb"&gt;`curl -s http://localhost:3000/signalk/v1/api/vessels/self/&amp;lt;path&amp;gt; | jq '.value'`&lt;/span&gt;
  where the SignalK dotted path becomes slashes.
&lt;span class="p"&gt;-&lt;/span&gt; A whole group at once (fewer calls):
  &lt;span class="sb"&gt;`curl -s http://localhost:3000/signalk/v1/api/vessels/self/electrical | jq`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Top-level groups: &lt;span class="sb"&gt;`navigation`&lt;/span&gt;, &lt;span class="sb"&gt;`environment`&lt;/span&gt;, &lt;span class="sb"&gt;`electrical`&lt;/span&gt;, &lt;span class="sb"&gt;`propulsion`&lt;/span&gt;,
  &lt;span class="sb"&gt;`tanks`&lt;/span&gt;, &lt;span class="sb"&gt;`watermaker`&lt;/span&gt;, &lt;span class="sb"&gt;`communication`&lt;/span&gt;, &lt;span class="sb"&gt;`design`&lt;/span&gt;, &lt;span class="sb"&gt;`notifications`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Units are SI:**&lt;/span&gt; speed m/s, angles &lt;span class="gs"&gt;**radians**&lt;/span&gt;, depth meters, temperature
  &lt;span class="gs"&gt;**Kelvin**&lt;/span&gt;. Convert for the Captain — knots, degrees, °C.
  (m/s→kn ×1.94384; rad→deg ×57.2958; K→°C −273.15.)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Battery current sign: positive = charging**&lt;/span&gt; (Victron convention).
&lt;span class="p"&gt;-&lt;/span&gt; History (&lt;span class="sb"&gt;`/signalk/v2/history/…`&lt;/span&gt;) is not available — answer "now."
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Never fabricate.**&lt;/span&gt; If curl fails or a path is missing, say so.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are always-on. Both answer the same questions. Now the receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1 — standing cost, the part everyone measures
&lt;/h2&gt;

&lt;p&gt;This is what you pay every turn, including turns where nobody asks about the boat.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;tokens/turn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MCP: seven tool schemas&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;874&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent file: the SignalK section&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;466&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Δ — agent file wins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−408&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How I counted — &lt;code&gt;tiktoken&lt;/code&gt;, &lt;code&gt;cl100k_base&lt;/code&gt;, pulling the tool definitions straight out of the source with &lt;code&gt;ast&lt;/code&gt; so there's no hand-transcription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/signalk_mcp/server.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keywords&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;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ast&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="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
         &lt;span class="k"&gt;if&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;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="ow"&gt;and&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AGENTS.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest note on the 874: that's the &lt;code&gt;_list_tools()&lt;/code&gt; definition block as written. Serialised to JSON it's 845; namespaced the way a client renders it (&lt;code&gt;mcp__signalk__depth_state&lt;/code&gt;) it's 887. So call it &lt;strong&gt;845–890 depending on your client&lt;/strong&gt; — the spread doesn't move anything below.&lt;/p&gt;

&lt;p&gt;So far the MCP-is-a-tax crowd is right. 408 tokens a turn, forever, is a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2 — the part the benchmarks skip
&lt;/h2&gt;

&lt;p&gt;The CLI benchmarks usually stop at the schema. But a tool call has a &lt;em&gt;result&lt;/em&gt;, and the result also lands in the context window.&lt;/p&gt;

&lt;p&gt;Here is what &lt;code&gt;curl&lt;/code&gt; gets back for depth — the whole reason the recipe exists:&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;"transducerToKeel"&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;"meta"&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;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Depth from the transducer to the bottom of the keel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"displayUnits"&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;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"depth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"targetUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"formula"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"inverseFormula"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"displayFormat"&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.0"&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-1.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"$source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"defaults"&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-01T13:35:02.076Z"&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;"surfaceToTransducer"&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="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&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;"belowTransducer"&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="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&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;"belowKeel"&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="err"&gt;...plus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;alarmMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warnMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;three&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;zones...&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;"belowSurface"&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="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&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;515 tokens, of which the five &lt;code&gt;value&lt;/code&gt; fields are about a dozen. Everything else is &lt;code&gt;meta&lt;/code&gt; — &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;units&lt;/code&gt;, and a &lt;code&gt;displayUnits&lt;/code&gt; object carrying &lt;code&gt;formula&lt;/code&gt;, &lt;code&gt;inverseFormula&lt;/code&gt;, &lt;code&gt;symbol&lt;/code&gt; and &lt;code&gt;displayFormat&lt;/code&gt; — repeated per leaf. The agent pays for all of it and then has to find the numbers inside.&lt;/p&gt;

&lt;p&gt;The MCP tool returns this instead:&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;"below_keel_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;36.42532195742289&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"below_surface_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;37.795321957422885&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"below_transducer_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;37.545321957422885&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;"36.4 metres under the keel, 37.8 metres total depth"&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-01T18:37:26.629Z"&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;92 tokens. Same numbers, plus a TTS-safe sentence the voice pipeline can read out verbatim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;read&lt;/th&gt;
&lt;th&gt;raw &lt;code&gt;curl&lt;/code&gt; response&lt;/th&gt;
&lt;th&gt;MCP tool result&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;environment/depth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;515&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;5.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;electrical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;431&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;4.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;navigation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;627&lt;/td&gt;
&lt;td&gt;~92&lt;/td&gt;
&lt;td&gt;6.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can't &lt;code&gt;jq&lt;/code&gt; your way out of most of this, either. Piping to &lt;code&gt;jq '.value'&lt;/code&gt; works for a single leaf, but the recipe's own advice — grab the whole subtree in one call to save round trips — is exactly the case where the meta bloat arrives in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breakeven formula
&lt;/h2&gt;

&lt;p&gt;Two levers pulling opposite directions. Set them against each other.&lt;/p&gt;

&lt;p&gt;Let &lt;strong&gt;ΔS&lt;/strong&gt; = the standing-cost difference (schemas − prose) and &lt;strong&gt;ΔR&lt;/strong&gt; = the per-read saving (raw response − tool result). Cost per turn at &lt;em&gt;k&lt;/em&gt; reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent file:   P + k · raw
MCP server:   S + k · tool

MCP is cheaper when   S + k·tool  &amp;lt;  P + k·raw
                             k    &amp;gt;  (S − P) / (raw − tool)
                             k*   =  ΔS / ΔR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this pair: &lt;strong&gt;k* = 408 / ~400 ≈ 1.02 reads per turn.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the finding. Not "MCP good" or "MCP is a tax" — &lt;em&gt;it pays for itself at roughly one tool call per turn.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Which cuts cleanly in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A general-purpose agent that touches the boat on one turn in twenty: the agent file is cheaper, and it isn't close.&lt;/li&gt;
&lt;li&gt;An agent whose entire job is the boat, where essentially every turn is a read: the MCP wins outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My constants aren't yours. Run the same three measurements on your own server — schema tokens, prose tokens, and a representative raw-vs-tool response pair — and divide. If your API returns lean JSON, ΔR collapses and the prose recipe wins at almost any &lt;em&gt;k&lt;/em&gt;. If your API is as metadata-heavy as SignalK's, ΔR is large and k* drops below 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt caching moves k*, and I measured by how much
&lt;/h2&gt;

&lt;p&gt;The standing cost is the MCP's only loss — and it's the exact part that caches. Tool schemas are a stable prefix. The per-read saving is on churny tokens that never cache. So caching should shrink ΔS and leave ΔR alone, which moves k* down.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to call cached tokens "10% of input" and be done. That's wrong, because it only counts the read side. A cache entry has to be &lt;em&gt;written&lt;/em&gt; first, and writes cost &lt;strong&gt;more&lt;/strong&gt; than uncached input.&lt;/p&gt;

&lt;p&gt;So rather than assume a hit rate, I aggregated the &lt;code&gt;usage&lt;/code&gt; field across the &lt;strong&gt;40 most recent agent session transcripts&lt;/strong&gt; for this project — &lt;strong&gt;3,782 billed requests&lt;/strong&gt; on a real runtime with &lt;code&gt;signalk-mcp&lt;/code&gt; attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache READ    698,670,375 tokens   94.33% of input-side
cache WRITE    41,945,413 tokens    5.66%
uncached input     29,139 tokens    ~0.00%

cold requests (zero cache read):  150 / 3,782  =  4.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every write in that sample was &lt;strong&gt;1-hour TTL&lt;/strong&gt; (&lt;code&gt;ephemeral_1h_input_tokens&lt;/code&gt;; &lt;code&gt;ephemeral_5m&lt;/code&gt; was zero), which matters — the price multiplier depends on TTL. Per the &lt;a href="https://docs.claude.com/en/docs/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;prompt-caching docs&lt;/a&gt;: cache read &lt;strong&gt;0.1×&lt;/strong&gt; base input, 5-minute write &lt;strong&gt;1.25×&lt;/strong&gt;, 1-hour write &lt;strong&gt;2×&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A standing block gets written on cold turns and read on warm ones, so its effective multiplier is &lt;code&gt;cold × write + warm × read&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cold turns&lt;/td&gt;
&lt;td&gt;4.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warm turns&lt;/td&gt;
&lt;td&gt;96.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;standing-block effective multiplier (1h TTL — what we ran)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.175×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same block at 5-minute TTL&lt;/td&gt;
&lt;td&gt;0.146×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blended multiplier across all input-side tokens&lt;/td&gt;
&lt;td&gt;0.208×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apply that to the 408-token standing delta and re-run the formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;408 × 0.175  =  ~72 effective tokens

k* = 72 / 400  ≈  0.18 reads per turn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;About one boat read every five or six turns.&lt;/strong&gt; Not the one-in-ten you get from the flat-10% shortcut — the write side is the expensive half and at 1-hour TTL it costs 2×.&lt;/p&gt;

&lt;p&gt;Caching still makes the MCP case &lt;em&gt;stronger&lt;/em&gt;, just less dramatically than the naive math suggests. And it's conservative in the MCP's favour for a second reason the formula doesn't capture: a tool result is uncached only on the turn it &lt;em&gt;arrives&lt;/em&gt;. After that it joins the cached prefix at 0.175× for the rest of the session. The ~400-tokens-per-read saving counts only the first turn; the MCP's leaner results keep paying rent afterwards.&lt;/p&gt;

&lt;p&gt;Three things to be honest about before you reuse the 0.175×:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;That's a hot session.&lt;/strong&gt; 96% warm is what sustained back-to-back work produces. A boat agent DM'd over Telegram with minutes between messages cold-starts far more often — the 5-minute TTL expiring between turns is a problem I've &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;hit before&lt;/a&gt;. So 0.175× is the &lt;em&gt;favourable&lt;/em&gt; end. A bursty agent lands nearer the uncached case — which widens the MCP's advantage rather than narrowing it: the standing penalty grows back toward 408, but so does the value of never re-sending 500-token blobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime attribution:&lt;/strong&gt; these are Claude Code sessions carrying the MCP, not the boat agent itself. Real cache behaviour of a real agent holding these schemas — but I didn't instrument the boat agent, and I'm not claiming I did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 1-hour TTL is a property of this session,&lt;/strong&gt; not a universal default. On the 5-minute default the multiplier is 0.146× and the standing penalty is ~59 tokens, a slightly cheaper MCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One takeaway for reading other people's numbers: the MCP-vs-CLI benchmarks I can find quote schema cost &lt;strong&gt;uncached&lt;/strong&gt;. On this measured multiplier that overstates the standing penalty by about 5.7×.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half that tokens don't measure
&lt;/h2&gt;

&lt;p&gt;Here's the thing that actually decided it, live off the boat's &lt;code&gt;environment.depth&lt;/code&gt; tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;environment.depth.belowTransducer   =  38.5187
environment.depth.transducerToKeel  =  -1.12      &amp;lt;-- stored NEGATIVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keel offset is stored as a &lt;strong&gt;negative&lt;/strong&gt; number. Under-keel clearance is therefore an &lt;em&gt;addition&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;belowTransducer + transducerToKeel  =  38.5187 + (-1.12)  =  37.40 m   ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent reading the raw tree and doing the obvious thing — &lt;em&gt;depth below the transducer, minus the keel offset&lt;/em&gt; — gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;38.5187 - (-1.12)  =  39.64 m   ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It reports 2.24 metres more clearance than the boat actually has, and the error points toward grounding.&lt;/strong&gt; In 38 m of water that's a rounding error. In 3 m it's the whole margin, delivered in a confident voice.&lt;/p&gt;

&lt;p&gt;Nothing in the token comparison sees this. The wrong answer costs the same as the right one.&lt;/p&gt;

&lt;p&gt;The MCP does the arithmetic in Python, where it can't be dropped:&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;below_keel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;below_keel_obj&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# SignalK's own belowKeel leaf
&lt;/span&gt;&lt;span class="bp"&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="nf"&gt;_depth_display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;below_keel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;below_surface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;below_transducer&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 tool description spends tokens specifically to stop the model freelancing: &lt;em&gt;"Do NOT read depth via read_sensor: the raw transducer path is NOT under-keel depth and will mislead. Do not guess depth paths or compute clearance yourself; call this."&lt;/em&gt; That instruction is part of the 874. It's not schema bloat; it's the thing being bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the prose recipe is really asking for
&lt;/h2&gt;

&lt;p&gt;Look back at the agent file with this in mind. Every bullet is a rule the model has to &lt;em&gt;remember and apply by hand&lt;/em&gt;, on every read, forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;m/s → knots          × 1.94384
radians → degrees    × 57.2958
Kelvin → °C          − 273.15
battery current      positive = charging (Victron convention)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four conversions, plus a sign convention, plus the keel-offset sign that isn't even in the list because nobody thought to write it down. Each is a coin flip on a bad turn — a long context, a distracting question, a smaller model. And the failure is silent: the model returns a number, not an error. "38 degrees" when the value was radians reads perfectly plausibly.&lt;/p&gt;

&lt;p&gt;In Python those are five lines that execute the same way every time:&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;tail&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_SPEED_KEYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.94384&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; knots&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;knots&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;tail&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_BEARING_KEYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;deg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;return&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;deg&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;° (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_degrees_to_compass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&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="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;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;273.15&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;°C&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;°C&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the summary is: &lt;strong&gt;the agent file is cheaper to carry and more expensive to be wrong with.&lt;/strong&gt; The token comparison is the easy half and it nearly cancels. The deciding factor is where the domain knowledge lives — in prose the model may skip, or in code it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, before you quote my numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The vessel is ashore.&lt;/strong&gt; This is Phase 0; the SignalK server runs a mock vessel plugin. The token counts, response shapes and the &lt;code&gt;transducerToKeel&lt;/code&gt; sign convention are all real. The &lt;em&gt;readings&lt;/em&gt; are synthetic — nobody is in 38 m of water off Boundary Pass right now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cl100k_base is a proxy.&lt;/strong&gt; Your model's tokenizer will give slightly different numbers. The ratios hold; the absolutes won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;874 vs 466 is my seven-tool server against my recipe.&lt;/strong&gt; The formula generalises. The constants absolutely do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not a controlled A/B.&lt;/strong&gt; The two setups differ in more than MCP-vs-file — different runtimes, Python vs &lt;code&gt;curl&lt;/code&gt;. It's a like-for-like cost comparison of two ways to give an agent the same capability, not a single-variable experiment.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I kept the MCP server, and the reason isn't the tokens — on tokens it's a coin flip that caching tips slightly my way. I kept it because &lt;code&gt;depth_state&lt;/code&gt; cannot get the sign wrong, and a prose bullet can.&lt;/p&gt;

&lt;p&gt;The rule of thumb I'd offer: &lt;strong&gt;compute k* = ΔS / ΔR before you argue about it&lt;/strong&gt;, and then ask the question the tokens can't answer — &lt;em&gt;is there any arithmetic in this domain that the model must not get wrong?&lt;/em&gt; If yes, that decides it regardless of which side of the breakeven you land on.&lt;/p&gt;

&lt;p&gt;The server 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), 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-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;Why we kept named MCP tools despite a 96% token saving&lt;/a&gt; — the same tradeoff one level down, inside the MCP server; &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 comes from, plus AGENTS.md vs SKILL.md measured the same way; and &lt;a href="https://dev.to%20post_url%202026-06-11-agent-skill-body-vs-base-system-prompt-always-on-conditional-deploy%20"&gt;Why your agent ignores its skill body but obeys the system prompt&lt;/a&gt; — always-on vs conditional, the first axis of this question.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>A persistent-queue HTTP reporter for distress traffic over flaky internet</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:05:03 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-persistent-queue-http-reporter-for-distress-traffic-over-flaky-internet-57nl</link>
      <guid>https://dev.to/clarkbw--/a-persistent-queue-http-reporter-for-distress-traffic-over-flaky-internet-57nl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Don't &lt;code&gt;fetch()&lt;/code&gt;-and-forget safety-relevant reports over marine internet. &lt;code&gt;signalk-distress-core&lt;/code&gt; ships a generic persistent-queue HTTP reporter: every payload is appended to a JSONL file &lt;em&gt;before&lt;/em&gt; the POST, delivered sequentially in order, retried forever on network errors, retried with a cap on 5xx, and dropped immediately on 400/404. The queue survives crashes and restarts. Jump to the response policy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters for safety at sea
&lt;/h2&gt;

&lt;p&gt;When a vessel in trouble hits the red distress button on a DSC VHF radio — or an EPIRB, SART, or MOB beacon starts transmitting on AIS — every receiver in range gets a copy. Shore stations hear most of it. But VHF is line-of-sight: behind an island, deep in a fjord, or simply far enough offshore, a distress burst can be heard by a nearby boat &lt;em&gt;and missed by every shore station&lt;/em&gt;. The copy your radio captured may be the only one with a position in it that made it anywhere.&lt;/p&gt;

&lt;p&gt;That's the case for relaying received distress traffic to a shore-side aggregation service: a crowd of receivers extends coverage beyond what any single station — afloat or ashore — can hear. &lt;a href="https://dscwatch.com" rel="noopener noreferrer"&gt;DSCWatch&lt;/a&gt; is one such network: it aggregates received DSC traffic from stations around the world into coverage maps and distress logs, and &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt; can submit every call it hears.&lt;/p&gt;

&lt;p&gt;But now look at the chain: radio → boat server → &lt;strong&gt;boat internet&lt;/strong&gt; → aggregation service. On a boat, the uplink is the least reliable link in that chain. Cellular dies in exactly the remote places where your copy of a distress call matters most. Satellite drops in rain, in swell, behind terrain. The server power-cycles with the house bank. If the reporting code is a bare &lt;code&gt;fetch()&lt;/code&gt;, every one of those blinks silently discards a heard distress call — and &lt;em&gt;"we dropped a MAYDAY on the floor because the internet blinked"&lt;/em&gt; is not an acceptable failure mode.&lt;/p&gt;

&lt;p&gt;So the delivery requirements write themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write-through persisted&lt;/strong&gt; — the report is on disk before the first delivery attempt, so a crash mid-flight loses nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart-proof&lt;/strong&gt; — whatever was undelivered at shutdown is delivered after startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-order&lt;/strong&gt; — DSC position &lt;em&gt;refinements&lt;/em&gt; (&lt;code&gt;$--DSE&lt;/code&gt;) must arrive after the alerts they refine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline catch-up&lt;/strong&gt; — hours of outage queue up and drain when connectivity returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a generic problem, not a DSC problem. So it lives in &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-distress-core&lt;/code&gt;&lt;/a&gt; (v0.5.0) as a payload-agnostic module — &lt;code&gt;signalk-dsc&lt;/code&gt; maps its events onto DSCWatch report bodies and calls &lt;code&gt;report()&lt;/code&gt;; &lt;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ais-distress&lt;/code&gt;&lt;/a&gt; can reuse it as-is. The rest of this post is the engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive versions, and why each one fails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fire-and-forget&lt;/strong&gt; is where everyone starts:&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;// Looks fine on shore wifi. On a boat it's a data shredder.&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&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;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offline for six hours crossing a strait? Six hours of received calls are gone. Process restarts? Anything in flight is gone. And on a boat both of those are &lt;em&gt;routine&lt;/em&gt;, not exceptional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry in memory&lt;/strong&gt; is the next instinct — keep an array, retry on failure:&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;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;queue&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;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;flushSoon&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;Better, until the process exits — SignalK restarts whenever you save any plugin's configuration, and the whole server power-cycles with the boat's electrical system. Everything queued is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch to disk on a timer&lt;/strong&gt; is the classic telemetry answer: buffer, flush every N seconds. But the window between "received" and "persisted" is exactly the crash window, and for distress traffic the acceptable size of that window is zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write-through, not batching
&lt;/h2&gt;

&lt;p&gt;The reporter appends the payload to a JSONL queue file &lt;em&gt;first&lt;/em&gt;, then immediately kicks the flusher. A healthy network still sees the POST within milliseconds; the disk write just happens to come before it.&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Enqueue and kick the flusher. Fire-behind: never throws. */&lt;/span&gt;
  &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;queue&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;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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="nx"&gt;maxQueue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;maxQueue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&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="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&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;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: enqueue failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;timer&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three deliberate choices in those few lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;appendFileSync&lt;/code&gt;, one JSON object per line.&lt;/strong&gt; Append is the cheapest durable write there is, and JSONL means a partial write corrupts &lt;em&gt;one line&lt;/em&gt;, not the file (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire-behind, never throws.&lt;/strong&gt; The caller is a hot path handling a distress alert — raising alarms, writing the logbook. Reporting must never take that path down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;attempts&lt;/code&gt; is in-memory only.&lt;/strong&gt; The disk format is just payloads. Retry accounting resets on restart, which is fine — a restart is a fresh chance, not a continuation of a grudge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Delivery is sequential and in order — one POST at a time, head of the queue first — so a &lt;code&gt;$--DSE&lt;/code&gt; position refinement can never overtake the distress alert it refines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response policy: what each HTTP answer means
&lt;/h2&gt;

&lt;p&gt;The core design decision is that &lt;strong&gt;"the POST failed" is three different situations&lt;/strong&gt;, and each one gets a different answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delivered (200 merged / 201 created)&lt;/td&gt;
&lt;td&gt;Dequeue, persist, reset backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;400&lt;/code&gt; / &lt;code&gt;404&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Bad body or rejected receiver key — a retry cannot fix it&lt;/td&gt;
&lt;td&gt;Drop it, move on (404 also signals the plugin once)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server reachable but erroring&lt;/td&gt;
&lt;td&gt;Retry with backoff, capped per entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network error / timeout&lt;/td&gt;
&lt;td&gt;Offline — the marine normal&lt;/td&gt;
&lt;td&gt;Keep &lt;strong&gt;everything&lt;/strong&gt;, back off, catch up later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the flush loop that implements it:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;flushing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;flushing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&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;let&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;body&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Network down (the offline-passage case): keep everything —&lt;/span&gt;
        &lt;span class="c1"&gt;// whatever is behind the head is failing for the same reason,&lt;/span&gt;
        &lt;span class="c1"&gt;// so in-order blocking costs nothing. Retry on a growing timer.&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;started&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="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: network error (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; queued`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;started&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;backoffMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;backoffBaseMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;permanentSignaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;continue&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;res&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;400&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// A retry cannot fix a bad body or a rejected receiver key.&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&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="s2"&gt;`reporter: dropped report (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;permanentSignaled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;permanentSignaled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nf"&gt;onPermanentError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// Server reachable but erroring (5xx): retry with backoff, but cap&lt;/span&gt;
      &lt;span class="c1"&gt;// per entry — one poison payload must not block the queue behind it.&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;attempts&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&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="s2"&gt;`reporter: dropped report after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; attempts (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="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="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;flushing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The asymmetry is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network errors never consume retry attempts.&lt;/strong&gt; Being offline for a nine-hour passage is not the payload's fault, so nothing is dropped for it, ever (up to &lt;code&gt;maxQueue&lt;/code&gt;). Head-of-line blocking is &lt;em&gt;free&lt;/em&gt; here — everything behind the head would fail for the same reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5xx retries are capped per entry&lt;/strong&gt; (&lt;code&gt;maxAttempts&lt;/code&gt;, default 10). Without the cap, one poison payload that reliably 500s would block every report behind it forever. With it, in-order delivery yields to liveness after a bounded effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;400/404 are dropped on the first response.&lt;/strong&gt; A malformed body or an unknown receiver key will be exactly as malformed on attempt fifty. Retrying a permanent rejection just hammers someone else's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404 signals the plugin exactly once&lt;/strong&gt; (&lt;code&gt;onPermanentError&lt;/code&gt; → &lt;code&gt;app.setPluginStatus&lt;/code&gt; in &lt;code&gt;signalk-dsc&lt;/code&gt;), and a later success resets the once-guard. Reports keep being attempted, so fixing the receiver key or the endpoint URL in config heals delivery without a restart — no "notification spam" and no "silently wedged until reboot".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the same lesson as our &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;ntfy 401 incident&lt;/a&gt;: a delivery path that fails must &lt;em&gt;say so&lt;/em&gt; somewhere a human looks, exactly once, and recover by itself when the config is fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surviving restarts: reload, torn lines, and the trim
&lt;/h2&gt;

&lt;p&gt;On construction the reporter reloads whatever the last process left behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="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;line&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="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;queue&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="na"&gt;payload&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// torn/corrupt line — skip it, keep the rest&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// no queue yet&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;torn-line skip&lt;/strong&gt; is why the disk format is JSONL and not one big JSON array: if the power died mid-append, exactly one line is unparseable. &lt;code&gt;JSON.parse&lt;/code&gt; per line means that line is skipped and every intact report before and after it still delivers. A JSON array with a torn tail would lose the whole file.&lt;/p&gt;

&lt;p&gt;Rewrites (dequeue, trim) go through a &lt;strong&gt;temp-file-and-rename&lt;/strong&gt; &lt;code&gt;persist()&lt;/code&gt; so the queue file is never observed half-written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;persist&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;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&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="nx"&gt;fs&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="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renameSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;strong&gt;&lt;code&gt;maxQueue&lt;/code&gt; (default 5000) drops oldest first&lt;/strong&gt; when a long outage overflows it. Newest-wins is the right policy for distress relay: the most recent calls are the ones a shore-side service can still act on, and 5000 queued reports is weeks of traffic for most stations anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that shipped in v0.5.0: the stale-flusher restart race
&lt;/h2&gt;

&lt;p&gt;v0.5.0 shipped with the design above and a green test suite — and a race that could destroy the queue file. It was caught the same morning, reviewing the shutdown path, and fixed in v0.5.1.&lt;/p&gt;

&lt;p&gt;The scenario: SignalK restarts a plugin whenever its configuration is saved. That calls &lt;code&gt;stop()&lt;/code&gt; on the old reporter instance and constructs a new one — which loads the queue file and starts appending to it. But &lt;code&gt;stop()&lt;/code&gt; can land &lt;strong&gt;while the old instance's POST is still in flight&lt;/strong&gt;. The old flusher is parked on &lt;code&gt;await fetchImpl(...)&lt;/code&gt;. When that stale fetch finally resolves:&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;// v0.5.0 — the resolving await charges ahead:&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// rewrites the queue file from the OLD instance's memory&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;persist()&lt;/code&gt; rewrites the whole file from the &lt;em&gt;old&lt;/em&gt; instance's in-memory queue — silently deleting every report the &lt;em&gt;new&lt;/em&gt; instance had appended since. A config tweak at the wrong millisecond erases a heard distress call from the queue. Exactly the failure mode this module exists to prevent, reintroduced by its own cleanup path.&lt;/p&gt;

&lt;p&gt;The fix is two guards — re-check &lt;code&gt;started&lt;/code&gt; after every &lt;code&gt;await&lt;/code&gt; boundary before touching shared state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;      } catch (err) {
        // Network down ...
&lt;span class="gi"&gt;+       if (!started) return;
&lt;/span&gt;        log(`reporter: network error (${err.message}) — ${queue.length} queued`);
        backoff();
        return;
      }
&lt;span class="gi"&gt;+     if (!started) return;
&lt;/span&gt;      if (res.ok) {
        queue.shift();
        persist();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch-branch guard matters too: without it, a network error resolving on a stopped reporter schedules a fresh backoff timer — a zombie instance that wakes up later and races the live one.&lt;/p&gt;

&lt;p&gt;Two consequences worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The in-flight entry stays queued for the new instance&lt;/strong&gt;, so it may be POSTed twice. That's deliberate: the DSCWatch backend deduplicates repeated submissions of the same call (DSC alerts auto-repeat on air anyway, and every repeat is reported), so at-least-once is the correct choice over at-most-once. For distress traffic, a duplicate is noise; a loss is the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is the generic &lt;code&gt;async&lt;/code&gt; shutdown lesson&lt;/strong&gt;, nothing marine about it: any &lt;code&gt;await&lt;/code&gt; in a loop is a suspension point where the world can change — including "you were stopped and replaced". Every resumption that mutates shared state (a file, a socket, a DB row) has to re-validate that it still owns that state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the race got a test — a gated fetch that holds the POST in flight across a &lt;code&gt;stop()&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stop() during an in-flight POST: the stale flusher never touches the queue file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;queueFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&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;resolveFetch&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;gate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolveFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;impl&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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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;calls&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;JSON&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hold the POST in flight&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;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&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;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;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// fetch is in flight&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// plugin restart: a new instance may now own the file&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// new instance's append&lt;/span&gt;
  &lt;span class="nf"&gt;resolveFetch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// stale POST finally resolves&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onDisk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;JSON&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;l&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;seq&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;onDisk&lt;/span&gt;&lt;span class="p"&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="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// stale flusher must NOT have shifted/persisted&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fetch timeouts: a black-holed link must not wedge the flusher
&lt;/h2&gt;

&lt;p&gt;v0.5.1 also added the one thing a default Node &lt;code&gt;fetch&lt;/code&gt; won't give you — a deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// default 30 s&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Marine links don't just go down — they &lt;em&gt;black-hole&lt;/em&gt;: a satellite terminal that accepts the TCP connection and then says nothing more, sometimes for minutes. Without a timeout, the single sequential flusher sits parked on that &lt;code&gt;await&lt;/code&gt; indefinitely, and the whole queue stalls behind a connection that will never answer. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; (stdlib since Node 17.3, no &lt;code&gt;AbortController&lt;/code&gt; boilerplate needed) turns that into an abort error, which takes the existing network-error path: keep the entry, back off, retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests pin the policy, not the implementation
&lt;/h2&gt;

&lt;p&gt;The response policy &lt;em&gt;is&lt;/em&gt; the contract, so the tests assert it directly against a scriptable &lt;code&gt;fetch&lt;/code&gt; stand-in — no HTTP server, no mocking framework, just &lt;code&gt;node:test&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;400 is dropped without retry; the next report still delivers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mockFetch&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="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;bad&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="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;good&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&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;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;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// bad tried exactly once&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;calls&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;good&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;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5xx retries are capped per entry, then the entry drops and the queue moves on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mockFetch&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="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;poison&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoffBaseMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoffMaxMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;poison&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="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;after&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&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;poisonTries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calls&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;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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;poison&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;poisonTries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// exactly maxAttempts, then dropped&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;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;calls&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;after&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;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&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 full suite pins every row of the policy table: 200-and-201 both accepted, in-order delivery, offline catch-up resuming in order with nothing dropped, restart recovery from the JSONL file, torn-line skip, &lt;code&gt;maxQueue&lt;/code&gt; oldest-first trim, the 404 signal-once-reset-on-success behaviour, and the stale-flusher race above. &lt;code&gt;fetchImpl&lt;/code&gt; is injectable for exactly this reason — the only seam the module needs.&lt;/p&gt;

&lt;p&gt;Two small design notes riding along in the same release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The receiver key is minted once and persisted&lt;/strong&gt; (&lt;code&gt;loadOrCreateReceiverKey&lt;/code&gt;): DSCWatch has no registration call — the first report for a new key creates the receiver record — so the only rule is &lt;em&gt;reuse the same value forever&lt;/em&gt;. A UUID written to a file in the plugin data directory does that; a station that wants attribution can configure its own key instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What leaves the boat is a pick-list, not a filter.&lt;/strong&gt; The report builder in &lt;code&gt;signalk-dsc&lt;/code&gt; copies an explicit allow-list of parsed call fields; local-only data (own-ship weather snapshots, spoken alert text, internal IDs) is never sent because it is never picked. Privacy boundaries built as "copy what's allowed" don't rot the way "strip what's forbidden" does. Reporting is on by default with a single toggle to keep all data on the boat — see the &lt;a href="https://github.com/sailingnaturali/signalk-dsc#dscwatch-reporting" rel="noopener noreferrer"&gt;plugin README&lt;/a&gt; for the exact field list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotchas if you build one of these
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Append-before-POST, always.&lt;/strong&gt; If the durable write isn't strictly first, there is a crash window, and on a boat the crash window gets hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify responses; don't treat "not ok" as one case.&lt;/strong&gt; Permanent rejections (400/404) retried forever are abuse; transient errors (5xx, timeouts) dropped immediately are data loss. The policy needs both, explicitly, with tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap retries per entry, not globally.&lt;/strong&gt; The cap exists to defeat poison-payload head-of-line blocking; a global cap would let one bad payload spend the whole budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check ownership after every &lt;code&gt;await&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;stop()&lt;/code&gt; + in-flight I/O + shared file = the stale-flusher race. Guard every resumption that touches shared state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timer.unref()&lt;/code&gt; the backoff timer&lt;/strong&gt; so a pending retry never keeps the host process alive past shutdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSONL over a JSON array&lt;/strong&gt; for any on-disk queue: torn writes cost one record, not the file.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This runs on the same all-electric-catamaran SignalK stack as the rest of this blog, relaying what the radio hears toward people who can use it. The reporter is payload-agnostic and MIT-licensed: &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/signalk-distress-core&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;lib/reporter.js&lt;/code&gt;, ~180 lines, tests included), consumed by &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt; for DSCWatch reporting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; how the DSC calls get captured in the first place — &lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;Logging VHF DSC distress calls in SignalK (PGN 129808)&lt;/a&gt; — and the delivery-path-monitoring lesson this reporter inherits — &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;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>dsc</category>
      <category>marine</category>
      <category>node</category>
    </item>
    <item>
      <title>Offline tidal currents from harmonic constituents — and when not to trust them</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:05:43 +0000</pubDate>
      <link>https://dev.to/clarkbw--/offline-tidal-currents-from-harmonic-constituents-and-when-not-to-trust-them-1j0p</link>
      <guid>https://dev.to/clarkbw--/offline-tidal-currents-from-harmonic-constituents-and-when-not-to-trust-them-1j0p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt;&lt;/a&gt; v0.7.0 keeps publishing tidal-current predictions when the boat loses internet: it bundles public-domain NOAA harmonic constituents and synthesizes slack/flood/ebb events offline with &lt;a href="https://github.com/neaps/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt;. But every fallback reading is labeled &lt;code&gt;source: "harmonic"&lt;/code&gt;, &lt;code&gt;live: false&lt;/code&gt;, and — at constricted passes — &lt;code&gt;unreliableForTransit: true&lt;/code&gt;, because harmonic slack timing at a tidal rapids can be off by tens of minutes. While the network &lt;em&gt;is&lt;/em&gt; up, the plugin logs harmonic-vs-live discrepancies locally so the error is measured, not guessed. Jump to the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Marine software has one non-negotiable requirement that most connected software gets to ignore: it has to keep working when the internet doesn't. A tidal-currents plugin that answers "when is slack at the pass?" only while the LTE link is up is worse than a paper tide book — because you'll have built your habits around asking it.&lt;/p&gt;

&lt;p&gt;This post is the deeper look promised in the &lt;a href="https://github.com/sailingnaturali/signalk-currents#offline-harmonic-fallback" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt; README&lt;/a&gt;: how the offline harmonic fallback works, why it deliberately announces its own unreliability at constricted passes instead of silently degrading, and how it measures itself against live data while it still can.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;signalk-currents&lt;/code&gt; publishes &lt;code&gt;environment.current&lt;/code&gt; and a &lt;code&gt;/currents&lt;/code&gt; resource by fetching &lt;strong&gt;live&lt;/strong&gt; predictions from the CHS (Canada) and NOAA (US) tides-and-currents APIs, one fetch per station per UTC day. Every poll needs connectivity. Kill the uplink and the failure looks like this in the server log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signalk-currents: station Seymour Narrows live fetch failed: fetch failed
signalk-currents: station Boundary Pass live fetch failed: fetch failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The per-day cache papers over short outages — per-day predictions are immutable, so anything already fetched keeps serving — but it's an in-memory &lt;code&gt;Map&lt;/code&gt; scoped to the process. Restart the server while offline, or sail past the configured &lt;code&gt;horizonDays&lt;/code&gt;, and currents go dark. On a boat, "restart while offline" is not an edge case; it's Tuesday.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Attempt 1: just serve the cache harder.&lt;/strong&gt; Persist the day cache to disk, fetch a longer horizon. This helps (and is a good idea anyway), but it's not synthesis — it can never answer beyond the last fetched day, and it does nothing for a station you never fetched. A cache is a memory, not a model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: bundle harmonic constituents for every gate.&lt;/strong&gt; This is the obvious move: tide clocks have synthesized predictions from harmonic constituents for a century, entirely offline. It works — for exactly half the cruising ground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOAA (US) current constituents:  public domain, freely redistributable ✓
CHS (Canada) current constituents: not redistributable ✗
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CHS harmonic constants are licensed non-commercial, non-redistributable, and not-for-navigation — you cannot ship them in an MIT-licensed npm package. And the Salish Sea's strong narrows — Seymour, Dodd, Gillard, Dent, Arran, Active, Porlier — are all CHS waters. The licensing wall lands precisely on the stations that matter most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: WebTide.&lt;/strong&gt; DFO's WebTide model has Salish-wide current constituents, but it's Crown copyright (redistribution needs written permission) and carries only 8 constituents with no shallow-water overtides — and overtides are exactly what constricted channels need (more on that below).&lt;/p&gt;

&lt;p&gt;So v1 bundles what's legal: nine public-domain NOAA current stations covering the US Salish passes — Deception Pass, Rosario Strait, Admiralty Inlet, San Juan Channel, Haro Strait (two bins), Guemes Channel (two bins), and Boundary Pass. The Canadian rapids stay API-only, and the plugin says so out loud rather than pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A tide-height engine, repurposed for currents
&lt;/h3&gt;

&lt;p&gt;There was no currents-capable harmonic synthesis library on npm. But &lt;a href="https://github.com/neaps/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt; (&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;, MIT) does the hard part — the astronomical argument and node-factor machinery behind summing constituents — for tide &lt;em&gt;heights&lt;/em&gt;. The trick: Neaps doesn't care what the scalar is. Feed it amplitudes in &lt;strong&gt;knots&lt;/strong&gt; instead of meters and its "water level" is the &lt;strong&gt;signed major-axis current velocity&lt;/strong&gt;. Positive level = flood, negative = ebb, zero crossing = slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/sources/harmonic.ts&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;synthesizeEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HarmonicStation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&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;predictor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTidePredictor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituents&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;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="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="nx"&gt;c&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="na"&gt;amplitude&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="nx"&gt;amplitudeKn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phase&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="nx"&gt;phaseDeg&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Flood/ebb peaks come straight from the extremes predictor.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extremes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;predictor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtremesPrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ponytail: assumes mean-zero constituents (no Z0/DC-offset term) — a level-curve max&lt;/span&gt;
  &lt;span class="c1"&gt;// is a positive (flood) peak; adding a Z0 constituent would shift the baseline and could flip labels.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;extremes&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;x&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;eventFromParts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="s1"&gt;ebb&lt;/span&gt;&lt;span class="dl"&gt;'&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Slack = sign change on a 1-minute timeline; linear-interpolate the crossing.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;predictor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTimelinePrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeFidelity&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="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;1&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&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="k"&gt;if &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;level&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="o"&gt;&amp;amp;&amp;amp;&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;level&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="o"&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;level&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="o"&gt;&amp;amp;&amp;amp;&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;level&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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt; &lt;span class="o"&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;level&lt;/span&gt; &lt;span class="o"&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;level&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&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;level&lt;/span&gt; &lt;span class="o"&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;level&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;t&lt;/span&gt; &lt;span class="o"&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;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;frac&lt;/span&gt; &lt;span class="o"&gt;*&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;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;a&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="nx"&gt;events&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;eventFromParts&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;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slack&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;events&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;x&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utc&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;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utc&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;events&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;"High tide" becomes a flood peak, "low tide" an ebb peak, and the output is the same &lt;code&gt;CurrentEvent[]&lt;/code&gt; (slack/flood/ebb) shape the live CHS/NOAA sources emit — a drop-in third source, no restructuring downstream.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;ponytail:&lt;/code&gt; comment: classifying an extremum as flood &lt;em&gt;because Neaps called it a high&lt;/em&gt; only works if the constituent sum is mean-zero. There's no &lt;code&gt;Z0&lt;/code&gt; (mean flow) term in the bundled data, so the baseline sits at zero and the labels hold. Add a mean-flow offset — say, a channel with persistent river outflow — and a weak "flood" maximum can sit entirely below zero: still ebbing, just less. The assumption is documented where it lives, because it's the first thing a future constituent refresh could silently break.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundling the constituents
&lt;/h3&gt;

&lt;p&gt;Constituents are fetched at build time from NOAA CO-OPS &lt;code&gt;mdapi&lt;/code&gt; &lt;code&gt;harcon&lt;/code&gt; metadata, converted, and committed — never fetched at runtime:&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;// scripts/refresh-constituents.ts (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CM_S_PER_KNOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;51.4444&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;constituents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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;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="nx"&gt;NEAPS_KNOWN&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="nx"&gt;constituentName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Number&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="nx"&gt;majorAmplitude&lt;/span&gt;&lt;span class="p"&gt;)&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="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;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="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="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituentName&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amplitudeKn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&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="nx"&gt;majorAmplitude&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;CM_S_PER_KNOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// cm/s -&amp;gt; knots&lt;/span&gt;
    &lt;span class="na"&gt;phaseDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&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="nx"&gt;majorPhaseGMT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                     &lt;span class="c1"&gt;// Greenwich phase, degrees&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three gotchas live in those six lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Units.&lt;/strong&gt; NOAA &lt;code&gt;harcon&lt;/code&gt; major-axis amplitudes are &lt;strong&gt;cm/s&lt;/strong&gt;; Neaps just sums whatever you hand it. Divide by 51.4444 once at generation time and every downstream &lt;code&gt;level&lt;/code&gt; is already knots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase reference.&lt;/strong&gt; Store &lt;code&gt;majorPhaseGMT&lt;/code&gt; (Greenwich phase, degrees). Neaps evaluates in UTC, so local-phase constants would shift every event by the timezone offset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent drops.&lt;/strong&gt; A constituent name Neaps doesn't know is &lt;strong&gt;silently ignored&lt;/strong&gt; — no error, just a subtly wrong prediction. The refresh script filters against an explicit &lt;code&gt;NEAPS_KNOWN&lt;/code&gt; set so anything unexpected is excluded deliberately at generation time, not accidentally at runtime.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;NOAA's own disclaimer applies and is carried in the data file's provenance header: the data is public domain, and predictions derived from it are &lt;strong&gt;unofficial&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aligning the horizon with the live path
&lt;/h3&gt;

&lt;p&gt;The live path fetches per UTC day. The harmonic path must synthesize the &lt;em&gt;same&lt;/em&gt; window, or the two series cover different spans and both fallback handoff and discrepancy comparison get skewed:&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;// Synthesize the whole horizon in one pass, aligned to UTC-day boundaries so the&lt;/span&gt;
&lt;span class="c1"&gt;// event window matches the live path (fetch.ts iterates UTC days from the same start).&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;synthesizeHorizon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HarmonicStation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;horizonDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;HarmonicDayData&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="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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCFullYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCMonth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&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;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&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;horizonDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&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;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;synthesizeEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;floodDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floodDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ebbDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ebbDir&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;h3&gt;
  
  
  Selection: live wins, fallback declares itself
&lt;/h3&gt;

&lt;p&gt;The refresh loop synthesizes harmonic data for every station with bundled constituents on &lt;em&gt;every&lt;/em&gt; cycle — not just offline ones — then selects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/select.ts&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;selectData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;liveData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DirData&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="nx"&gt;harmonicData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DirData&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="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noaa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Selected&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;liveData&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;liveData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;live&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonicData&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;harmonicData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;harmonic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every reading that leaves the plugin carries provenance. In the &lt;code&gt;/currents&lt;/code&gt; resource:&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;"stationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUG1701"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deception Pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"harmonic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"live"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unreliableForTransit"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&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;"utc"&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-07-16T14:02:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speedKn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="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;"utc"&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-07-16T17:10:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ebb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speedKn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.8&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;And on the &lt;code&gt;environment.current&lt;/code&gt; delta, as SignalK meta:&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;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment.current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;source&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;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;// 'chs' | 'noaa' | 'harmonic'&lt;/span&gt;
    &lt;span class="na"&gt;live&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;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// false when synthesized offline&lt;/span&gt;
    &lt;span class="na"&gt;unreliableForTransit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;station&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiresLive&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;live&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="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;object&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;requiresLive&lt;/code&gt; is a per-station config flag, pre-set on the constricted passes in the default station list. The design rule: &lt;strong&gt;the plugin states facts; it never suppresses a value and never editorializes.&lt;/strong&gt; A harmonic slack estimate at Deception Pass is still useful for rough planning — deciding whether to look, not when to go. Whatever consumes the data (a chart plotter, an agent, a human reading JSON) decides what to do with &lt;code&gt;unreliableForTransit: true&lt;/code&gt;. Silently degrading — serving a synthesized number in the same shape as an authoritative one — is how someone times a rapids transit on a model that's 25 minutes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why harmonic predictions fall short at constricted passes
&lt;/h2&gt;

&lt;p&gt;This is the part the README promises and defers, so here it is. Harmonic prediction is a &lt;strong&gt;linear&lt;/strong&gt; model: the current is a sum of fixed sinusoids at astronomical frequencies,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v(t) = Σᵢ Aᵢ · cos(ωᵢ·t − φᵢ)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with amplitudes and phases fitted to a historical current-meter record. That model is excellent where the tide behaves like a superposition of long waves — open coast, wide straits. A constricted pass breaks each of its assumptions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The flow is hydraulic, not wavelike.&lt;/strong&gt; At a narrows like Seymour (which routinely exceeds 15 knots) the current is driven by the water-level &lt;em&gt;difference&lt;/em&gt; between the basins at each end. The velocity responds roughly as the square root of that head difference, through quadratic bottom/form friction. A square root of a sum of sinusoids is not a sum of sinusoids — the response is nonlinear by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nonlinearity pumps energy into overtides the bundle truncates.&lt;/strong&gt; The nonlinear terms generate shallow-water constituents — M4, M6, MS4 and friends, at multiples and sums of the astronomical frequencies — which is what makes real rapids curves asymmetric: fast-rising floods, long-tailed ebbs, double-humped peaks. The bundled stations carry 23–29 constituents each (only the ones Neaps knows); the residual distortion the fit couldn't capture doesn't vanish, it lands in the error — concentrated where the curve shape matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The error concentrates at slack — the only moment you care about.&lt;/strong&gt; At peak flow, a 10% amplitude error changes nothing about your decision; you weren't going through Dent Rapids at max ebb anyway. Near slack, the head difference across the pass is small, so &lt;em&gt;everything the constituents don't model&lt;/em&gt; — wind setup, atmospheric pressure gradients, river discharge in freshet, the truncated overtides — shifts the zero crossing directly. The steeper, shorter slack window at a strong narrows means a modest phase error translates to arriving with real current already running. That's the mechanism behind "off by tens of minutes," and it's why the flag is named &lt;code&gt;unreliableForTransit&lt;/code&gt; and not something politer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The fit itself is weaker.&lt;/strong&gt; Height constituents come from decades-long water-level records at permanent gauges. Current constituents come from comparatively short current-meter deployments in turbulent, stratified water, resolved per depth bin. The inputs are noisier before the model's structural limits even apply.&lt;/p&gt;

&lt;p&gt;None of this makes the harmonic fallback useless — Boundary Pass under harmonic synthesis is a perfectly serviceable planning baseline. It makes the fallback &lt;em&gt;conditionally&lt;/em&gt; trustworthy, and the condition is knowable per station in advance. That's exactly what a config flag is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the fallback while you still have ground truth
&lt;/h2&gt;

&lt;p&gt;The claim above — "off by tens of minutes" — deserves numbers, per station. The trick is that the best time to measure an offline fallback is when you're online: every poll cycle where a station has &lt;em&gt;both&lt;/em&gt; live and harmonic data, the plugin diffs them and appends a line to a local JSONL log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/compare.ts — deltas are signed (harmonic − live):&lt;/span&gt;
&lt;span class="c1"&gt;// +slackDeltaMin = harmonic slack is later&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;computeDiscrepancy&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="kr"&gt;string&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;harmonic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Discrepancy&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;ls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextSlackAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextSlackAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&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;lp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;peakRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;peakRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonic&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;utc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;stationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slackDeltaMin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ls&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hs&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;hs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&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="na"&gt;peakRateDeltaKn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hp&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="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lp&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="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 plaintext"&gt;&lt;code&gt;&amp;lt;SignalK dataDir&amp;gt;/signalk-currents-discrepancies.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per station per poll: how far off the harmonic next-slack estimate is (signed minutes) and how far off the peak rate is (signed knots). Local file only — nothing is phoned home. The write is best-effort and swallowed on failure, because diagnostics must never take down the data path they're diagnosing.&lt;/p&gt;

&lt;p&gt;The design intent: when someone eventually asks "how wrong is the fallback at &lt;em&gt;this&lt;/em&gt; station?", the answer is a &lt;code&gt;jq&lt;/code&gt; one-liner over months of accumulated rows, not a shrug. The follow-up post with a season of measured deltas is the plan; this post is the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NOAA &lt;code&gt;harcon&lt;/code&gt; amplitudes are cm/s&lt;/strong&gt; — convert to knots (÷ 51.4444) at generation time, not runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neaps silently drops constituent names it doesn't know.&lt;/strong&gt; Filter against an explicit known set when generating the bundle, or you'll ship a prediction missing constituents with no error anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flood/ebb labels assume mean-zero constituents.&lt;/strong&gt; A &lt;code&gt;Z0&lt;/code&gt;/mean-flow term would shift the baseline and can flip extremum labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Align synthesis windows to UTC-day boundaries&lt;/strong&gt; to match a per-day live fetch path, or fallback handoffs jump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You may not be able to redistribute your local constituents.&lt;/strong&gt; NOAA is public domain; CHS is not. Check before bundling anything Canadian — the licensing boundary decides your offline coverage map, not your code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance is part of the data model, not a log line.&lt;/strong&gt; &lt;code&gt;source&lt;/code&gt; / &lt;code&gt;live&lt;/code&gt; / &lt;code&gt;unreliableForTransit&lt;/code&gt; ride on the resource payload &lt;em&gt;and&lt;/em&gt; the SignalK meta, so every consumer sees them — not just whoever reads the server log.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This plugin is part of the open-source navigation stack for an all-electric charter catamaran that will spend its life transiting exactly these passes — which is why the fallback that keeps currents flowing offline also has to be honest about where it can't be trusted. Code, bundled constituents, and the refresh script: &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-currents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;why generic weather MCPs fail for marine navigation&lt;/a&gt; — the same verify-against-reality instinct, with NDBC buoys as ground truth — and &lt;a href="https://dev.to%20post_url%202026-06-21-llm-agent-confabulation-inventing-infrastructure-and-data-provenance%20"&gt;LLM agents confabulating data provenance&lt;/a&gt;, the failure mode provenance fields exist to prevent.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>currents</category>
      <category>tides</category>
    </item>
    <item>
      <title>Running OpenClaw on a Raspberry Pi alongside SignalK</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:50:26 +0000</pubDate>
      <link>https://dev.to/clarkbw--/running-openclaw-on-a-raspberry-pi-alongside-signalk-53ob</link>
      <guid>https://dev.to/clarkbw--/running-openclaw-on-a-raspberry-pi-alongside-signalk-53ob</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Running &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt; — a self-hosted, multi-channel agent gateway — on a Raspberry Pi 5 next to &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt;, so you can DM the boat over Telegram and get live vessel readings back. The traps that cost me time are up front — the &lt;code&gt;gateway.mode&lt;/code&gt; start-block, SI-unit conversion, no history without InfluxDB, and a Chesterton's-fence with the &lt;code&gt;ollama&lt;/code&gt; plugin — then the walkthrough, then what I found tuning the standing prompt down. Jump to the gotchas.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no existing writeup for this combination anywhere I could find, so here's the whole thing: what to know before you start, the setup that actually works, and the token-cost tuning that made it cheap enough to leave running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;OpenClaw is a Node/TypeScript daemon you run on your own hardware; you talk to an LLM-backed agent over chat channels (Telegram, Slack, etc.). SignalK is the open marine data server that normalises NMEA 2000/0183 into one JSON model. Put them on the same Pi and you get a boat assistant you DM from your phone that can read live vessel state.&lt;/p&gt;

&lt;p&gt;They share the box but stay in their own lanes. OpenClaw reads SignalK over HTTP — it does not run inside the marine stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Raspberry Pi 5 (8 GB)
  ┌─────────────────────────────────────────────┐
  │  SignalK (Docker)          OpenClaw (native) │
  │  localhost:3000  ◀── HTTP ──  gateway daemon │
  │  + InfluxDB/Grafana           (systemd user) │
  └─────────────────────────────────────────────┘
            ▲                          ▲
       NMEA 2000/0183            Telegram DM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SignalK is typically already running in Docker on &lt;code&gt;localhost:3000&lt;/code&gt; next to InfluxDB and Grafana. OpenClaw installs natively alongside it. No MCP server, no plugin inside SignalK — the agent just curls the SignalK REST API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas first
&lt;/h2&gt;

&lt;p&gt;These are the ones that don't show up until you hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The gateway won't start without &lt;code&gt;gateway.mode&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If the config is missing &lt;code&gt;gateway.mode&lt;/code&gt;, the daemon refuses to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gateway start blocked: set gateway.mode=local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Onboarding sets this for you. But if you hand-edit the config and drop the key — or a tool rewrites it — OpenClaw treats the missing key as suspicious/clobbered config and blocks the start rather than guessing. The fix is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config &lt;span class="nb"&gt;set &lt;/span&gt;gateway.mode &lt;span class="nb"&gt;local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Or re-run onboarding with &lt;code&gt;openclaw onboard --mode local&lt;/code&gt;.) See the &lt;a href="https://docs.openclaw.ai/gateway/troubleshooting" rel="noopener noreferrer"&gt;gateway troubleshooting docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SignalK values are SI — the agent must convert
&lt;/h3&gt;

&lt;p&gt;SignalK stores everything in SI units. A raw read &lt;em&gt;looks&lt;/em&gt; fine and is silently wrong for a human:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;speed      → m/s     (× 1.94384 for knots)
angles     → radians (× 57.2958 for degrees)
temperature→ Kelvin  (− 273.15 for °C)
depth      → metres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;environment.wind.speedApparent&lt;/code&gt; comes back as &lt;code&gt;8.5&lt;/code&gt; — that's 8.5 &lt;strong&gt;m/s&lt;/strong&gt;, i.e. ~16.5 knots, not 8.5 knots. If you don't tell the agent to convert, it will happily report the SI number as if it were the human unit. Bake the conversions into the agent's instructions (or a skill), and spell out the target units.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No history without InfluxDB
&lt;/h3&gt;

&lt;p&gt;SignalK's REST API serves live values fine anonymously. But the history endpoint returns a 404 unless the InfluxDB-backed history plugin is running:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v2/history/values?paths&lt;span class="o"&gt;=&lt;/span&gt;electrical.batteries.house.stateOfCharge
&lt;span class="c"&gt;# 404 unless signalk-to-influxdb (+ history API) is installed and running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it the agent is live-only — it answers "what's the depth &lt;em&gt;now&lt;/em&gt;," not "what was the minimum overnight." If you want the agent to reason over trends, stand up the history plugin first; otherwise scope its instructions to present-tense questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;jq&lt;/code&gt; isn't installed by default
&lt;/h3&gt;

&lt;p&gt;Raspberry Pi OS ships without &lt;code&gt;jq&lt;/code&gt;. If your agent instructions pipe curl through &lt;code&gt;jq&lt;/code&gt;, they'll fail on a fresh box:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/environment/depth/belowTransducer | jq .value
bash: jq: &lt;span class="nb"&gt;command &lt;/span&gt;not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two options: &lt;code&gt;sudo apt install jq&lt;/code&gt;, or just don't — hand the agent the raw JSON and let it parse. An LLM reads &lt;code&gt;{ "value": 4.2, "timestamp": "…" }&lt;/code&gt; without help. I went with raw JSON; one fewer moving part on the boat.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Chesterton's fence — the &lt;code&gt;ollama&lt;/code&gt; plugin does more than you think
&lt;/h3&gt;

&lt;p&gt;I disabled the &lt;code&gt;ollama&lt;/code&gt; plugin assuming it was only a (dead, for me) web-search provider. Later a local Ollama model failed to load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No API provider registered for api: ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ollama&lt;/code&gt; plugin &lt;em&gt;also&lt;/em&gt; registers the Ollama API runtime — the thing that lets you use Ollama as a &lt;strong&gt;model backend&lt;/strong&gt;, not just a search tool. Disabling it pulled the backend out from under the model. The lesson is the old one: don't disable a plugin until you know &lt;em&gt;everything&lt;/em&gt; it provides. (Re-enabling it fixed the load.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install (native)
&lt;/h3&gt;

&lt;p&gt;OpenClaw supports Docker, but on a Pi that's already running SignalK's own Docker stack I installed OpenClaw natively and let it manage its own daemon — it keeps the marine containers isolated and the agent's lifecycle separate. The installer targets Node 24 by default (the supported range is Node 22.22.3+, 24.15+, or 25.9+) and handles the runtime for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://openclaw.ai/install.sh | bash
&lt;span class="c"&gt;# or: npm install -g openclaw@latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the guided setup — it walks you through auth (an Anthropic API key, or an OAuth login), model choice, and 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;openclaw onboard
openclaw onboard &lt;span class="nt"&gt;--install-daemon&lt;/span&gt;   &lt;span class="c"&gt;# installs a systemd user service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a headless Pi, the user service dies at logout unless you enable lingering:&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;sudo &lt;/span&gt;loginctl enable-linger &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the piece that makes it survive a reboot. See the &lt;a href="https://docs.openclaw.ai/install" rel="noopener noreferrer"&gt;install docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram
&lt;/h3&gt;

&lt;p&gt;Make a bot with &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt;, then point OpenClaw at the token — either in config or via env:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config &lt;span class="nb"&gt;set &lt;/span&gt;channels.telegram.botToken &lt;span class="s2"&gt;"&amp;lt;BOT_TOKEN&amp;gt;"&lt;/span&gt;
&lt;span class="c"&gt;# or export TELEGRAM_BOT_TOKEN=&amp;lt;BOT_TOKEN&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep DMs gated with pairing so a stranger who finds the bot can't drive your boat:&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;// channels.telegram&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;"dmPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pairing"&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;DM the bot, then approve the code it gives you (codes expire after an hour):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw pairing approve telegram &amp;lt;CODE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details in the &lt;a href="https://docs.openclaw.ai/channels/telegram" rel="noopener noreferrer"&gt;Telegram channel docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading SignalK over HTTP — no MCP needed
&lt;/h3&gt;

&lt;p&gt;SignalK's REST API is anonymous when &lt;code&gt;allow_readonly&lt;/code&gt; is on, so you don't need a custom SignalK MCP server for reads. Give the agent the &lt;code&gt;exec&lt;/code&gt; tool and let it curl. A SignalK dotted path maps straight to URL slashes:&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;# environment.depth.belowTransducer  →  .../environment/depth/belowTransducer&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/environment/depth/belowTransducer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.2&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-07-27T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"meta"&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;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&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;Grab a whole subtree in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/electrical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one prerequisite: &lt;code&gt;exec&lt;/code&gt; only exists in certain tool profiles — which is the first tuning finding below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning finding 1 — skill vs the agent file
&lt;/h2&gt;

&lt;p&gt;OpenClaw injects workspace files into context every turn: a &lt;code&gt;SOUL.md&lt;/code&gt; persona and an &lt;code&gt;AGENTS.md&lt;/code&gt; instruction/memory file. Skills live at &lt;code&gt;workspace/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt; — the skill's name+description is advertised every turn, but its body loads on demand when the skill fires.&lt;/p&gt;

&lt;p&gt;So where do the SignalK-reading instructions belong — always-on in &lt;code&gt;AGENTS.md&lt;/code&gt;, or in a &lt;code&gt;SKILL.md&lt;/code&gt;? Intuition says the skill: pay for the body only when you use it. I measured prompt tokens per turn both ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Turn&lt;/th&gt;
&lt;th&gt;in AGENTS.md (always-on)&lt;/th&gt;
&lt;th&gt;as a skill (on-demand)&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No-tool question&lt;/td&gt;
&lt;td&gt;13,808&lt;/td&gt;
&lt;td&gt;13,380&lt;/td&gt;
&lt;td&gt;−428 (skill wins)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single boat read&lt;/td&gt;
&lt;td&gt;14,070&lt;/td&gt;
&lt;td&gt;14,427&lt;/td&gt;
&lt;td&gt;+357 (skill loses)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi boat read&lt;/td&gt;
&lt;td&gt;16,134&lt;/td&gt;
&lt;td&gt;16,628&lt;/td&gt;
&lt;td&gt;+494 (skill loses)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Counterintuitive but consistent: &lt;strong&gt;for an agent whose whole job is this domain, the always-on agent file is slightly cheaper.&lt;/strong&gt; The skill's &lt;em&gt;description&lt;/em&gt; still sits in context every turn (alongside every other skill's), and loading its &lt;em&gt;body&lt;/em&gt; on a domain turn costs more than the tiny always-on snippet it replaced. The skill only wins when most turns &lt;em&gt;don't&lt;/em&gt; touch the domain — and for a boat agent, most turns do.&lt;/p&gt;

&lt;p&gt;(This is the same always-on-vs-conditional split we hit on a different framework — see the related post below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning finding 2 — cutting the ~14k standing prompt
&lt;/h2&gt;

&lt;p&gt;Every turn, even a no-tool question, carries a ~14k-token standing prompt: base preamble + tool schemas + workspace files. That's structural to a general-purpose gateway. Three things I tried to shrink it, in order of what actually worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked — the tool profile
&lt;/h3&gt;

&lt;p&gt;OpenClaw gates tools by &lt;code&gt;tools.profile&lt;/code&gt;: &lt;code&gt;minimal&lt;/code&gt; (just &lt;code&gt;session_status&lt;/code&gt;), &lt;code&gt;coding&lt;/code&gt; (filesystem + runtime/&lt;code&gt;exec&lt;/code&gt; + web + more), &lt;code&gt;messaging&lt;/code&gt;, and &lt;code&gt;full&lt;/code&gt;. &lt;strong&gt;Only &lt;code&gt;coding&lt;/code&gt; grants both &lt;code&gt;exec&lt;/code&gt; and filesystem&lt;/strong&gt; — which is why the SignalK-over-curl approach needs it.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;coding&lt;/code&gt; also drags in a pile of dev-tool schemas a boat agent never calls. If all you need is shell + file read/write, drop to &lt;code&gt;minimal&lt;/code&gt; and additively re-allow just those two groups:&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;// tools&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;group:runtime&lt;/code&gt; is &lt;code&gt;exec&lt;/code&gt;/process; &lt;code&gt;group:fs&lt;/code&gt; is read/write/edit. That cut &lt;strong&gt;~5,000 tokens/turn (~25%)&lt;/strong&gt; — pure schema for tools the agent never uses — with zero functional loss. See the &lt;a href="https://docs.openclaw.ai/gateway/config-tools" rel="noopener noreferrer"&gt;tool-profile docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What did NOT work — disabling plugins
&lt;/h3&gt;

&lt;p&gt;I assumed the loaded plugins (browser, canvas, phone-control, talk-voice…) were bloating the prompt. Disabling four of them saved &lt;strong&gt;~270 tokens&lt;/strong&gt; — nothing.&lt;/p&gt;

&lt;p&gt;Why: tool schemas are gated by tool &lt;em&gt;policy&lt;/em&gt;, not plugin &lt;em&gt;state&lt;/em&gt;. Once the &lt;code&gt;minimal&lt;/code&gt; profile excludes a tool, its schema is already gone whether or not the plugin loads. So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disabling a plugin does not save prompt tokens if the profile already denies its tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(Still disable unused plugins — for the Pi's RAM, CPU, and boot time. Just don't expect a token win.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The real lever — caching, not token count
&lt;/h3&gt;

&lt;p&gt;The standing prompt bills at full price only on a &lt;em&gt;cold&lt;/em&gt; turn. OpenClaw auto-injects Anthropic &lt;code&gt;cache_control&lt;/code&gt; on the stable prefix, so the ~14k reads from cache at ~10% on warm turns. I watched it in one session: the cold turn &lt;em&gt;wrote&lt;/em&gt; ~14k tokens to cache; the next same-session turn &lt;em&gt;read&lt;/em&gt; ~14k from cache and &lt;em&gt;wrote&lt;/em&gt; only ~150.&lt;/p&gt;

&lt;p&gt;For bursty use — messages minutes apart, which is exactly how you DM a boat — the default 5-minute cache TTL keeps cold-starting. Bump it:&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;// long cache retention → 1h TTL&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;"cacheRetention"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"long"&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 version caveat: OpenClaw is careful to order the stable context files ahead of the churny heartbeat file so heartbeat updates don't bust the cached prefix — but older builds had a cache-busting bug where a per-message value landed &lt;em&gt;inside&lt;/em&gt; the cached block and re-wrote the whole prefix every turn. If your "cached" reads look like full-price writes, check your version. See the &lt;a href="https://docs.openclaw.ai/reference/prompt-caching" rel="noopener noreferrer"&gt;prompt-caching docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus — a watch that stays quiet
&lt;/h2&gt;

&lt;p&gt;Once the agent can read SignalK, a natural next step is a scheduled check that only speaks when something's wrong. OpenClaw's cron supports a deterministic &lt;code&gt;--trigger-script&lt;/code&gt; gate: the script inspects SignalK (any non-&lt;code&gt;normal&lt;/code&gt; notification, or a battery/depth/tank threshold), returns &lt;code&gt;{ fire, message?, state? }&lt;/code&gt;, and the agent only composes a Telegram heads-up when &lt;code&gt;fire&lt;/code&gt; is true — de-duped on &lt;code&gt;state&lt;/code&gt; so it alerts on &lt;em&gt;change&lt;/em&gt;, not every tick. A watch that's silent when all's well. That's its own post; the &lt;a href="https://docs.openclaw.ai/automation/cron-jobs" rel="noopener noreferrer"&gt;cron docs&lt;/a&gt; have the shape.&lt;/p&gt;

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

&lt;p&gt;This came out of building an AI ops layer for an all-electric charter catamaran, where "what's our depth?" should be a Telegram DM away and the standing prompt cheap enough to leave running on a Pi. If you're standing this up yourself, the two references worth bookmarking are the &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw docs&lt;/a&gt; and &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;signalk.org&lt;/a&gt; — the rest is the six lines of gotchas above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-11-agent-skill-body-vs-base-system-prompt-always-on-conditional-deploy%20"&gt;Why your agent ignores its skill body but obeys the system prompt&lt;/a&gt; — the always-on-vs-conditional split behind tuning finding 1; and &lt;a href="https://dev.to%20post_url%202026-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;Why we kept named MCP tools despite a 96% token saving&lt;/a&gt; — the tokens-vs-reliability tradeoff for boat agents.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openclaw</category>
      <category>signalk</category>
      <category>raspberrypi</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
