<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://docs.dev-eric.work/journal</id>
    <title>Eric Lee — Engineering Journal Blog</title>
    <updated>2026-08-19T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://docs.dev-eric.work/journal"/>
    <subtitle>Eric Lee — Engineering Journal Blog</subtitle>
    <icon>https://docs.dev-eric.work/img/favicon.svg</icon>
    <entry>
        <title type="html"><![CDATA[Ingress and VIP design for a home Kubernetes cluster]]></title>
        <id>https://docs.dev-eric.work/journal/2026/08/19/ingress-and-vip-design-for-a-home-kubernetes-cluster</id>
        <link href="https://docs.dev-eric.work/journal/2026/08/19/ingress-and-vip-design-for-a-home-kubernetes-cluster"/>
        <updated>2026-08-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[How traffic reaches a small cluster from the internet and from the LAN, and why a single virtual IP is not the same as high availability.]]></summary>
        <content type="html"><![CDATA[<p>A home cluster serves two audiences: the public internet and devices on the
local network. Both paths eventually need to land on the ingress controller,
and both have their own failure modes. This is the design the cluster settled
on, and the part of it that is still weaker than it looks.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-building-blocks">The building blocks<a href="#the-building-blocks" class="hash-link" aria-label="Direct link to The building blocks" title="Direct link to The building blocks">​</a></h2><ul><li><strong>Ingress controller</strong>: Traefik, installed as the cluster's ingress, routing
by hostname to Services.</li><li><strong>LoadBalancer implementation</strong>: Traefik is exposed through a Service of
type <code>LoadBalancer</code>. With no cloud provider, something in the cluster has to
implement that — either a LoadBalancer controller or a shared virtual IP.</li><li><strong>API VIP</strong>: kube-vip provides a virtual IP for the Kubernetes API server so
the control plane has a stable address independent of which node is master.</li><li><strong>External entry</strong>: a tunnel daemon runs as a container and forwards public
traffic to the ingress origin on the local network. Internal entry uses
local DNS.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-loadbalancer-actually-means-here">What "LoadBalancer" actually means here<a href="#what-loadbalancer-actually-means-here" class="hash-link" aria-label="Direct link to What &quot;LoadBalancer&quot; actually means here" title="Direct link to What &quot;LoadBalancer&quot; actually means here">​</a></h2><p>On a cloud provider, a <code>LoadBalancer</code> Service provisions a real balancer. On
bare metal it is whatever the implementation provides. In this cluster,
Traefik's LoadBalancer status lists <strong>all node addresses</strong> — the controller
lets any node accept and forward traffic for the service. That is convenient
and it is also easy to misread: the external address field is not a single
stable VIP you can point DNS at.</p><p>The API VIP is separate and intentionally so. If the application VIP and the
API VIP shared an address, a routing problem in one would take out the other.
An early attempt to give applications a managed VIP failed and was reverted
(see the
<a href="/journal/2026/04/19/kube-vip-arp-pollution-and-etcd-quorum-collapse">post-mortem on the ARP fallout</a>).
The design keeps them apart while the application path is reworked.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-two-entry-paths">The two entry paths<a href="#the-two-entry-paths" class="hash-link" aria-label="Direct link to The two entry paths" title="Direct link to The two entry paths">​</a></h2><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">Internet → tunnel daemon → ingress origin → Traefik → Service → Pod</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">LAN      → local DNS     → node address   → Traefik → Service → Pod</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The external path has one manual seam: the tunnel's origin address is
configured in the tunnel dashboard, not in Git. The internal path has a
different problem.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-dns-single-point-of-failure">The DNS single point of failure<a href="#the-dns-single-point-of-failure" class="hash-link" aria-label="Direct link to The DNS single point of failure" title="Direct link to The DNS single point of failure">​</a></h2><p>Internal hostnames resolved through the local DNS server, and the wildcard
record pointed at <strong>one node's address</strong>. Every internal request that entered
the cluster went through that node first.</p><p>This is important to name correctly: the bottleneck is not Traefik. Traefik
can be reached on any node. The single point of failure is the DNS record.
If that node is down, internal clients cannot reach the cluster even though
the ingress path itself is healthy.</p><p>The intended topology is:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">local DNS → application VIP → Traefik → any node</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Once an application VIP exists, the DNS record stops being a SPOF; the VIP
takes over that role, and the VIP is a single address by design — which means
it needs its own answer for what happens when its holder fails.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="layers-and-what-manages-them">Layers and what manages them<a href="#layers-and-what-manages-them" class="hash-link" aria-label="Direct link to Layers and what manages them" title="Direct link to Layers and what manages them">​</a></h2><table><thead><tr><th>Layer</th><th>Mechanism</th><th>Managed by</th></tr></thead><tbody><tr><td>API endpoint</td><td>kube-vip virtual IP</td><td>cluster config</td></tr><tr><td>Application entry</td><td>Traefik LoadBalancer</td><td>cluster manifests</td></tr><tr><td>Public origin</td><td>tunnel daemon</td><td>external dashboard</td></tr><tr><td>Internal names</td><td>local DNS server</td><td>DNS configuration</td></tr><tr><td>Routing rules</td><td>Ingress objects</td><td>Git</td></tr></tbody></table><p>The pattern worth noticing: the pieces closest to the network edge are the
least declarative. The tunnel origin and the DNS records are infrastructure
state living outside Git, and they are exactly the pieces that are hardest to
reconstruct after an outage.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaways">Takeaways<a href="#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways">​</a></h2><ul><li>A <code>LoadBalancer</code> Service on bare metal is an implementation, not a cloud
guarantee. Understand what backs it.</li><li>Keep the API VIP and any application VIP separate.</li><li>A DNS record pointing at a single node is a single point of failure even
when the ingress controller is healthy.</li><li>Inventory the manual seams — tunnel configuration, DNS records — before an
incident forces you to.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Migrating a K3s etcd cluster from Wi-Fi to wired networking]]></title>
        <id>https://docs.dev-eric.work/journal/2026/07/13/migrating-k3s-etcd-from-wifi-to-wired-networking</id>
        <link href="https://docs.dev-eric.work/journal/2026/07/13/migrating-k3s-etcd-from-wifi-to-wired-networking"/>
        <updated>2026-07-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Moving a three-node control plane off Wi-Fi to eliminate etcd peer timeouts and restore a stable quorum.]]></summary>
        <content type="html"><![CDATA[<p>The small Kubernetes cluster at home started life on Wi-Fi. The nodes were
spread across rooms, wireless was good enough for light workloads, and running
a cable to every control plane felt like unnecessary work. That held up for a
while — until the etcd members started complaining.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-symptom">The symptom<a href="#the-symptom" class="hash-link" aria-label="Direct link to The symptom" title="Direct link to The symptom">​</a></h2><p>The cluster has three control-plane nodes running embedded etcd for
high availability. Over time the logs filled with etcd peer timeouts, the
API server slowed down, and leader elections started flapping. Under load the
whole control plane became briefly unavailable.</p><p>The first reaction was tuning. Raising the etcd heartbeat interval and
election timeout (<code>heartbeat-interval=500</code>, <code>election-timeout=5000</code>) made the
errors less frequent, which was enough to call the problem solved for a
while. It was not a fix: it only made etcd more forgiving of a network that
should never have been carrying peer traffic in the first place.</p><p>etcd is unusually sensitive to latency and jitter between members. Wireless
is exactly that: retries, roaming, and interference that show up as
milliseconds of variance at the worst possible moment.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-fix-wire-the-control-planes">The fix: wire the control planes<a href="#the-fix-wire-the-control-planes" class="hash-link" aria-label="Direct link to The fix: wire the control planes" title="Direct link to The fix: wire the control planes">​</a></h2><p>All three control-plane nodes were moved to wired Ethernet (<code>eth0</code>). The
worker nodes did not need to change — etcd peers are the strict requirement,
not every workload.</p><p>The wireless interfaces were kept as backups with a higher route metric, so a
cable failure does not take a node off the network.</p><h3 class="anchor anchorWithStickyNavbar_LWe7" id="migrating-one-etcd-member">Migrating one etcd member<a href="#migrating-one-etcd-member" class="hash-link" aria-label="Direct link to Migrating one etcd member" title="Direct link to Migrating one etcd member">​</a></h3><p>The migration is a per-node procedure, and the ordering matters:</p><ol><li><p>Confirm the node's new wired address.</p></li><li><p>Install <code>etcdctl</code> on <strong>every</strong> control-plane node. K3s does not bundle it,
and it has to be reinstalled after a node is reprovisioned.</p></li><li><p>From a healthy member, list the cluster and update the peer URL:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">etcdctl member list</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">etcdctl member update </span><span class="token operator">&lt;</span><span class="token plain">member-id</span><span class="token operator">&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">\</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  --peer-urls</span><span class="token operator">=</span><span class="token plain">https://</span><span class="token operator">&lt;</span><span class="token plain">new-address</span><span class="token operator">&gt;</span><span class="token plain">:2380</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The certificates live under
<code>/var/lib/rancher/k3s/server/tls/etcd/</code> (<code>server-ca.crt</code>, <code>client.crt</code>,
<code>client.key</code>).</p></li><li><p>Update <code>/etc/rancher/k3s/config.yaml</code> on that node with the new
<code>node-ip</code> and <code>advertise-address</code>.</p></li><li><p>Restart K3s on the node.</p></li><li><p>Verify: the node is <code>Ready</code>, <code>etcdctl member list</code> shows the new peer URL,
and the node annotations point at the wired address.</p></li></ol><h3 class="anchor anchorWithStickyNavbar_LWe7" id="the-caveat-that-almost-broke-quorum">The caveat that almost broke quorum<a href="#the-caveat-that-almost-broke-quorum" class="hash-link" aria-label="Direct link to The caveat that almost broke quorum" title="Direct link to The caveat that almost broke quorum">​</a></h3><p>If the node being migrated is the <strong>only healthy member</strong>, it cannot rejoin
on a new peer URL that the other members do not know about yet. The safe
sequence is to boot it on the old address first, let quorum recover, then
update the peer URL and restart.</p><p>Losing quorum mid-migration is the failure mode to design around: on a
three-member cluster, one member can be offline at a time, and no more.</p><h3 class="anchor anchorWithStickyNavbar_LWe7" id="kube-vip">kube-vip<a href="#kube-vip" class="hash-link" aria-label="Direct link to kube-vip" title="Direct link to kube-vip">​</a></h3><p>The virtual IP for the API server is managed by kube-vip, which had been
configured against the wireless interface. That was switched to <code>eth0</code> as
part of the migration. The gratuitous-ARP workarounds that had accumulated
while the control planes were on Wi-Fi — needed because wireless clients can
miss ARP updates — are no longer required.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-is-not-managed-declaratively">What is not managed declaratively<a href="#what-is-not-managed-declaratively" class="hash-link" aria-label="Direct link to What is not managed declaratively" title="Direct link to What is not managed declaratively">​</a></h2><p>Node-level changes — <code>/etc/rancher/k3s/config.yaml</code>, the <code>etcdctl</code> binary,
etcd member peer URLs — are outside GitOps. The cluster's workloads are
reconciled from Git, but the datastore identity of each control-plane node is
manual state. After this migration those details are worth recording
somewhere, because the next person to touch the cluster will need them.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaways">Takeaways<a href="#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways">​</a></h2><ul><li>etcd peer traffic belongs on a wired link. Tuning timeouts buys time, not
stability.</li><li>Install <code>etcdctl</code> on every control-plane node before you need it.</li><li>On a three-node control plane, migrate one member at a time and never lose
two.</li><li>Keep the wireless interface as a backup with a higher metric instead of
deleting it.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Skills and evals: teaching an AI agent platform workflows]]></title>
        <id>https://docs.dev-eric.work/journal/2026/05/26/skills-and-evals-teaching-an-ai-agent-platform-workflows</id>
        <link href="https://docs.dev-eric.work/journal/2026/05/26/skills-and-evals-teaching-an-ai-agent-platform-workflows"/>
        <updated>2026-05-26T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[What it takes to make an AI coding agent reliably integrate a platform — a workflow skill, a real codebase as a fixture, and a human-graded evaluation loop.]]></summary>
        <content type="html"><![CDATA[<p>Giving an agent tools is the easy part. Making it follow your platform's
integration workflow — correctly, on a real codebase, without rewriting the app
around it — is where the work is.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="a-skill-is-a-workflow-not-a-readme">A skill is a workflow, not a README<a href="#a-skill-is-a-workflow-not-a-readme" class="hash-link" aria-label="Direct link to A skill is a workflow, not a README" title="Direct link to A skill is a workflow, not a README">​</a></h2><p>The first instinct is to hand the agent the documentation. That fails for a
predictable reason: documentation describes the API, but integration is a
sequence of decisions.</p><p>The skill we wrote encodes that sequence:</p><ul><li><strong>Choose the path.</strong> Drop-in card, custom UI through context hooks, or an
action-link flow.</li><li><strong>Choose the authentication mode.</strong> On-chain wallet signing, off-chain
sign-in, or a special case.</li><li><strong>Confirm the inputs.</strong> Tenant id, card id, environment, wallet chain, keys,
signing method. If something is missing, ask — do not guess.</li><li><strong>Integrate minimally.</strong> Fit the existing app structure; do not invent a new
architecture around it.</li></ul><p>It opens with a short decision tree, then goes straight to implementation.
Every section exists to remove a decision the agent would otherwise make
wrongly by default.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-failure-modes-are-the-content">The failure modes are the content<a href="#the-failure-modes-are-the-content" class="hash-link" aria-label="Direct link to The failure modes are the content" title="Direct link to The failure modes are the content">​</a></h2><p>Half of the skill is a list of ways integrations go wrong: provider mounted in
the wrong place, auth mode mismatched with the wallet connection, subscription
values parsed without checking the shape they depend on. These are not API
errors — they are judgment errors. A skill is valuable exactly to the extent
that it prevents them.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="you-cannot-eyeball-this">You cannot eyeball this<a href="#you-cannot-eyeball-this" class="hash-link" aria-label="Direct link to You cannot eyeball this" title="Direct link to You cannot eyeball this">​</a></h2><p>A skill that reads well can still make an agent worse. The only way to know is
to run it against a real task and compare.</p><p>The evaluation setup has four parts:</p><ul><li><strong>Evals</strong> — the test cases: a prompt, the expected behavior, and what a
reviewer should look at.</li><li><strong>Fixtures</strong> — an immutable baseline codebase to run against.</li><li><strong>Workspaces</strong> — the artifacts of each run: final response, transcript, diff,
timing.</li><li><strong>A runner</strong> — the orchestration that clones the fixture, creates a
workspace, launches the agent, and saves everything for review.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="choices-for-the-first-iteration">Choices for the first iteration<a href="#choices-for-the-first-iteration" class="hash-link" aria-label="Direct link to Choices for the first iteration" title="Direct link to Choices for the first iteration">​</a></h2><p><strong>A real codebase, pinned.</strong> The fixture is a real downstream React
application cloned at a fixed commit — not a toy example and not a vendored
snapshot. The agent has to work inside an app with its own wallet setup,
routing and conventions.</p><p><strong>With and without.</strong> Every eval runs twice: once with the skill available,
once without. The difference between the runs is the signal.</p><p><strong>Human review first.</strong> Grading is done by a person against a rubric, not by
assertions. The rubric scores seven things: path selection, auth mode, provider
placement, required parameters, preservation of the existing app, code
correctness, and minimality of the diff.</p><p><strong>Assets in Git, artifacts out.</strong> The skill, eval definitions, rubric and
fixture metadata are committed. Run workspaces are gitignored — they are
evidence, not source.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-we-learned">What we learned<a href="#what-we-learned" class="hash-link" aria-label="Direct link to What we learned" title="Direct link to What we learned">​</a></h2><ul><li><strong>Run the eval before trusting the skill.</strong> The differences were subtle: the
same integration wired two different ways, one of which would rot.</li><li><strong>A pinned real codebase finds problems a synthetic one cannot.</strong> It also
forces the runner to be reproducible.</li><li><strong>Human grading is the right MVP.</strong> Automating a rubric before you know which
dimensions matter freezes the wrong criteria.</li><li><strong>Minimality is a scored dimension.</strong> An agent that completes the task by
rewriting half the app has failed.</li></ul><p>The next steps are the obvious ones: more fixtures (an off-chain auth app),
automated graders once the rubric stabilizes, and benchmark aggregation across
iterations. But the order matters — skills, real fixtures, human review, and
only then automation.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[How a leftover kube-vip DaemonSet poisoned ARP and broke etcd quorum]]></title>
        <id>https://docs.dev-eric.work/journal/2026/04/19/kube-vip-arp-pollution-and-etcd-quorum-collapse</id>
        <link href="https://docs.dev-eric.work/journal/2026/04/19/kube-vip-arp-pollution-and-etcd-quorum-collapse"/>
        <updated>2026-04-19T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A post-mortem of a cluster outage caused by a reverted VIP experiment, a stuck DaemonSet and a router ARP table with one MAC address for many IPs.]]></summary>
        <content type="html"><![CDATA[<p>This is the story of an outage that started as a small networking experiment
and ended with a control plane that could not maintain quorum. The lesson is
about ordering: reverting a Git change does not help if the component that
reads Git depends on the datastore that is already broken.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-experiment">The experiment<a href="#the-experiment" class="hash-link" aria-label="Direct link to The experiment" title="Direct link to The experiment">​</a></h2><p>A three-node K3s control plane, all nodes on Wi-Fi at the time, already had a
virtual IP for the API server managed by kube-vip. The next step was to give
application ingress a virtual IP as well, using kube-vip's services mode. The
manifest was committed as a DaemonSet that would announce per-service <code>/32</code>
addresses.</p><p>It did not work as intended, and the change was reverted.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-failure-chain">The failure chain<a href="#the-failure-chain" class="hash-link" aria-label="Direct link to The failure chain" title="Direct link to The failure chain">​</a></h2><ol><li>The revert commit was pushed, but Flux could not delete the DaemonSet from
the cluster: etcd quorum was already broken, so the GitOps reconciliation
loop had nothing to read state from and nothing to write state to.</li><li>Because the object still existed in etcd, the kubelet on the node
recreated the pod — as kubelets do — even though the DaemonSet had been
removed from Git.</li><li>The recreated pod announced its <code>/32</code> service addresses on the wireless
interface.</li><li>Wi-Fi is a shared medium. The access point's ARP table learned one MAC
address for many IP addresses, a poisoned entry that then spread.</li><li>etcd peer traffic on port 2380 could no longer resolve its peers: ARP for
the node addresses failed or returned the wrong destination.</li><li>Quorum collapsed, which kept Flux unable to remove the object, which kept
the pod alive. A loop.</li></ol><h2 class="anchor anchorWithStickyNavbar_LWe7" id="diagnosis">Diagnosis<a href="#diagnosis" class="hash-link" aria-label="Direct link to Diagnosis" title="Direct link to Diagnosis">​</a></h2><p>The clues were unusually confusing:</p><ul><li>The wrong primary address was showing on the wireless interface.</li><li>An SSH session to one node would sometimes land on a different one.</li><li>mDNS names still resolved, which made the network look healthy.</li><li>The one reliable vantage point was a machine outside the affected segment;
from there the pattern was obvious.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="recovery">Recovery<a href="#recovery" class="hash-link" aria-label="Direct link to Recovery" title="Direct link to Recovery">​</a></h2><p>Order is everything here. The goal is to stop the thing that keeps making
things worse before repairing the datastore.</p><ol><li><p>Stop K3s on all nodes and remove the stray <code>/32</code> addresses from the
interfaces they were announced on.</p></li><li><p>On one control-plane node, run a single-node cluster reset
(<code>k3s server --cluster-reset</code>) so the datastore can be opened again.</p></li><li><p>Start K3s on that node and delete the offending DaemonSet.</p></li><li><p>On each of the other control-plane nodes, wipe <strong>both</strong>:</p><ul><li><code>/var/lib/rancher/k3s/server/db/etcd</code></li><li><code>/var/lib/rancher/k3s/server/tls/etcd</code></li></ul><p>Deleting only the datastore is not enough — the etcd certificates have to
go too, or the peer handshake fails against the reset member. This detail
cost the most time.</p></li><li><p>Restart K3s on each node and let them rejoin, then clean up the leftover
static pods with <code>crictl stop</code> / <code>crictl rm</code>.</p></li></ol><h2 class="anchor anchorWithStickyNavbar_LWe7" id="prevention">Prevention<a href="#prevention" class="hash-link" aria-label="Direct link to Prevention" title="Direct link to Prevention">​</a></h2><ul><li>Do not run kube-vip in services mode on a Wi-Fi network. Announcements that
rely on gratuitous ARP only update the router; Wi-Fi peers can miss them.</li><li>Use MetalLB or a static LoadBalancer address instead.</li><li>Take an etcd snapshot before any kube-vip change.</li><li>Understand that a GitOps revert is not an escape hatch: if the datastore is
down, the revert cannot be applied. Recovery has to be manual first,
declarative second.</li><li>On a three-member etcd cluster, protect quorum above all else. One node can
fail; the second failure is the outage.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaways">Takeaways<a href="#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways">​</a></h2><p>The experiment was reverted. The pod was not. The datastore held the state
that kept the pod alive, and the pod was the reason the datastore could not
recover. Breaking that loop meant going to the nodes and fixing etcd by hand,
in the right order, before Git could take over again.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Building an MCP server for an existing platform]]></title>
        <id>https://docs.dev-eric.work/journal/2026/04/13/building-an-mcp-server-for-an-existing-platform</id>
        <link href="https://docs.dev-eric.work/journal/2026/04/13/building-an-mcp-server-for-an-existing-platform"/>
        <updated>2026-04-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Turning an existing platform API into something an AI agent can use — the design decisions behind a small, local-first MCP server.]]></summary>
        <content type="html"><![CDATA[<p>A platform with a mature API is not automatically usable by an AI agent. Agents
need tools with names, schemas and boundaries they can reason about — and a way
to run them without handing credentials to a stranger. This is the story of
adding that layer to an existing notification platform.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-goal">The goal<a href="#the-goal" class="hash-link" aria-label="Direct link to The goal" title="Direct link to The goal">​</a></h2><p>The platform already had everything a notification product needs: tenant
configuration, alert subscriptions, message publishing, and SDKs for web and
server environments. What it did not have was an interface an agent could
operate.</p><p>The target workflows were concrete:</p><ul><li>an agent detects a large on-chain transaction and notifies subscribed wallets</li><li>a liquidation warning goes out before a position is at risk</li><li>a community manager drafts and broadcasts an announcement</li><li>a developer asks an AI IDE to publish a test notification</li></ul><p>Non-agent workflows still had the raw GraphQL and REST APIs. The new layer was
for autonomy.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="decisions-before-code">Decisions before code<a href="#decisions-before-code" class="hash-link" aria-label="Direct link to Decisions before code" title="Direct link to Decisions before code">​</a></h2><p>Nine decisions shaped the implementation. The important ones:</p><p><strong>MCP and a companion skill.</strong> MCP is the runtime integration: it gives the
agent executable tools. A companion skill is the guidance layer: when to use
which tool, how to reason about payloads, and when to ask the user instead of
guessing. Tools alone are not enough for a domain with tenant-specific data
shapes.</p><p><strong>Local-first, stdio only.</strong> The server is distributed as an npm package the
user runs themselves. Credentials live in environment variables on the user's
machine and never leave it. No hosted infrastructure, no SSE transport, no
OAuth flow to build.</p><p><strong>Exactly three tools.</strong> <code>publish_message</code>, <code>get_active_alerts</code> and
<code>get_tenant_config</code>. A small surface is a feature: agents select tools more
reliably from a short list, and every tool is a commitment to maintain.</p><p><strong>Raw payloads, no universal schema.</strong> The payload for a message is defined by
tenant, topic and template, so the server passes it through as an object rather
than inventing an abstraction that would be wrong half the time.</p><p><strong>No package installation.</strong> The agent may only use the predefined tools or
documented direct API calls. It must not install or execute arbitrary packages
— a whitelist keeps the blast radius small.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-architecture">The architecture<a href="#the-architecture" class="hash-link" aria-label="Direct link to The architecture" title="Direct link to The architecture">​</a></h2><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">AI agent</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">   │  stdio</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">   ▼</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">local MCP server  ──HTTPS──▶  platform GraphQL + REST APIs</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">   │</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">   └── reuses the existing server-side SDK</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The server is a thin wrapper over the platform's Node SDK: it reuses the
GraphQL and REST clients instead of reimplementing them. Configuration comes
from environment variables, the client is initialized lazily on the first tool
call, and token refresh is handled transparently.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-the-tools-look-like">What the tools look like<a href="#what-the-tools-look-like" class="hash-link" aria-label="Direct link to What the tools look like" title="Direct link to What the tools look like">​</a></h2><p><code>get_tenant_config</code> returns the tenant's configuration and its events with
metadata — the information an agent needs to reason about everything else.</p><p><code>get_active_alerts</code> returns the subscribers currently subscribed to an event,
with cursor pagination normalized into a simple page object.</p><p><code>publish_message</code> takes an event id, the raw payload object and an optional
wallet target list. It maps the target list onto the API's wallet-specific send
path and passes the payload through unchanged.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-part-that-is-not-code">The part that is not code<a href="#the-part-that-is-not-code" class="hash-link" aria-label="Direct link to The part that is not code" title="Direct link to The part that is not code">​</a></h2><p>The hardest part is payload reasoning. A tenant's topic might require a field
that exists nowhere in the metadata. There is no universal function from
"event" to "valid payload" — the knowledge lives in the tenant's configuration
and sometimes only in the head of the person asking.</p><p>That is what the companion skill is for. It teaches the agent to inspect the
configuration first, to prefer the MCP path, to fall back to documented direct
API calls when MCP is unavailable, and — most importantly — to ask the user
when the required shape is ambiguous.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="validation-in-a-real-agent">Validation in a real agent<a href="#validation-in-a-real-agent" class="hash-link" aria-label="Direct link to Validation in a real agent" title="Direct link to Validation in a real agent">​</a></h2><p>The server passed its unit-level checks, but the interesting validation was end
to end, inside an actual agent runtime:</p><ul><li>the server boots, and a missing credential produces an actionable error
instead of a crash</li><li>the agent discovers the tools and the companion skill</li><li>configuration lookup, alert pagination and a real broadcast publish all
succeed</li><li>given an ambiguous payload, the agent inspects the configuration before
assuming a shape</li><li>when MCP is unavailable, the agent can still explain the direct API path</li></ul><p>That last set of scenarios is where the design is really tested. A tool server
is easy to demo and hard to make dependable.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-i-would-keep">What I would keep<a href="#what-i-would-keep" class="hash-link" aria-label="Direct link to What I would keep" title="Direct link to What I would keep">​</a></h2><ul><li><strong>Small tool surfaces.</strong> Three well-named tools beat fifteen convenient ones.</li><li><strong>Pass-through payloads.</strong> Abstractions over data shapes you do not control
become translation layers you cannot maintain.</li><li><strong>Local-first credentials.</strong> It removes an entire class of security review.</li><li><strong>A companion skill.</strong> The agent needs judgment about the domain, not just a
list of functions.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Modernizing wallet connectivity with EIP-6963]]></title>
        <id>https://docs.dev-eric.work/journal/2026/04/01/modernizing-wallet-connectivity-with-eip-6963</id>
        <link href="https://docs.dev-eric.work/journal/2026/04/01/modernizing-wallet-connectivity-with-eip-6963"/>
        <updated>2026-04-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A wallet extension stopped injecting its custom global and the connect flow broke. Fixing it meant moving the whole wallet layer to standard discovery.]]></summary>
        <content type="html"><![CDATA[<p>Wallet integrations age badly. One vendor-specific global can disappear in a single extension update — and when it does, the failure looks like a bug in your product.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-symptom">The symptom<a href="#the-symptom" class="hash-link" aria-label="Direct link to The symptom" title="Direct link to The symptom">​</a></h2><p>Customers reported the same thing: install the new version of a wallet extension, open the dapp, click the wallet tile in the connect modal — and get redirected to the wallet vendor's homepage instead of connecting.</p><p>The modal was behaving exactly as written. It could not find the wallet, so it assumed the extension was missing and offered to install it.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-root-cause">The root cause<a href="#the-root-cause" class="hash-link" aria-label="Direct link to The root cause" title="Direct link to The root cause">​</a></h2><p>The SDK had a dedicated integration for this wallet, built around a custom global that old versions injected into the page. Every detection path led back to that global:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:rgb(80, 250, 123)">getWalletFromWindow</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">async</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">typeof</span><span class="token plain"> window </span><span class="token operator">===</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'undefined'</span><span class="token plain"> </span><span class="token operator">||</span><span class="token plain"> </span><span class="token operator">!</span><span class="token plain">window</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">WalletGlobal</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">throw</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token plain"> </span><span class="token class-name">Error</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">"wallet is not installed"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token comment" style="color:rgb(98, 114, 164)">// ...</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The new wallet release moved to MPC-based key management and stopped injecting the legacy global entirely. By then the old extension was already on its way out: the vendor had stopped shipping updates for it, with store removal announced for the following month.</p><p>What the new wallet does instead is announce itself through <strong>EIP-6963</strong>, the discovery standard for injected wallets: every provider dispatches an announcement event carrying metadata, including an <code>rdns</code> identifier and a display name. Wallets that never touch a custom global are still discoverable — if you listen for announcements.</p><p>Wallet detection was vendor-shaped. The standard existed precisely to avoid that.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-fix">The fix<a href="#the-fix" class="hash-link" aria-label="Direct link to The fix" title="Direct link to The fix">​</a></h2><p>The SDK already had a generic injected-wallet path that listens for EIP-6963 announcements and matches a wallet by substring on <code>rdns</code> or name:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">providers</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">find</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">p</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">=&gt;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    p</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">info</span><span class="token operator">?.</span><span class="token plain">rdns</span><span class="token operator">?.</span><span class="token function" style="color:rgb(80, 250, 123)">toLowerCase</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">includes</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">walletName</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">toLowerCase</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">||</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    p</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">info</span><span class="token operator">?.</span><span class="token plain">name</span><span class="token operator">?.</span><span class="token function" style="color:rgb(80, 250, 123)">toLowerCase</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">includes</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">walletName</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">toLowerCase</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The fix was two lines of substance: move the wallet from the dedicated legacy hook to the generic injected hook, and update the install URL to point at the current wallet. The public interface did not change at all.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-cleanup-that-followed">The cleanup that followed<a href="#the-cleanup-that-followed" class="hash-link" aria-label="Direct link to The cleanup that followed" title="Direct link to The cleanup that followed">​</a></h2><p>The incident exposed how much dedicated machinery existed for a single wallet:</p><ul><li>a legacy hook of a few hundred lines, no longer imported by anything</li><li>a dedicated wallet class separate from the generic EVM wallet</li><li>registry entries: a standalone category, and the wallet listed as a native integration instead of an injected one</li><li>a special case in the wallet instance factory</li><li>tests for all of the above</li></ul><p>With the wallet flowing through standard discovery, none of it was necessary. The registry now treats it like any other injected EVM wallet: no special category, no special hook, no special factory branch.</p><p>While in there, the type layer was renamed to describe the chain family instead of a wallet implementation — EVM keys, Cosmos keys, Solana keys, Cardano keys. The rename has no runtime impact, but it stops the public types from baking vendor names into anything that imports them. Removing the dedicated class from the public package is a breaking change for the small set of consumers importing it directly, so it ships with the next major version and a short migration note.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-this-taught-us">What this taught us<a href="#what-this-taught-us" class="hash-link" aria-label="Direct link to What this taught us" title="Direct link to What this taught us">​</a></h2><ul><li><strong>Prefer standards-based discovery.</strong> EIP-6963 exists so integration code does not depend on whichever global a vendor injected this year.</li><li><strong>One integration per wallet does not scale.</strong> A registry entry plus a generic path covers wallets that follow the standard; dedicated code is for genuinely special cases.</li><li><strong>Fix the incident and the cleanup together.</strong> The "small fix" turned out to be the front door to removing hundreds of lines of abstraction. Left separate, the cleanup would probably never have happened.</li><li><strong>Name types after concepts, not vendors.</strong> Chain families are stable; wallet branding is not.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Reading CPU temperature into Home Assistant]]></title>
        <id>https://docs.dev-eric.work/journal/2026/03/30/cpu-temperature-in-home-assistant</id>
        <link href="https://docs.dev-eric.work/journal/2026/03/30/cpu-temperature-in-home-assistant"/>
        <updated>2026-03-30T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Home Assistant has no CPU temperature sensor out of the box. On a Raspberry Pi, the kernel already exposes one.]]></summary>
        <content type="html"><![CDATA[<p>Home Assistant does not expose the host's CPU temperature as an entity. On a
Raspberry Pi that is a useful number to have — for dashboards, but also for
automations that react to a hot board.</p><p>The kernel already provides it. Thermal zones are exposed under <code>/sys</code>:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">cat</span><span class="token plain"> /sys/class/thermal/thermal_zone0/temp</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)"># 47200</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The value is in millidegrees Celsius, and <code>type</code> reports <code>cpu-thermal</code>.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-options">The options<a href="#the-options" class="hash-link" aria-label="Direct link to The options" title="Direct link to The options">​</a></h2><ul><li><strong>command_line sensor</strong> — reads the file directly. Native, no add-on, no
extra service. Chosen here.</li><li><strong>Glances add-on</strong> — much broader system stats, at the cost of a running
service and more resource use.</li><li><strong>System Monitor</strong> — built in, but on Home Assistant OS it does not offer
CPU temperature.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="keeping-configurationyaml-readable">Keeping configuration.yaml readable<a href="#keeping-configurationyaml-readable" class="hash-link" aria-label="Direct link to Keeping configuration.yaml readable" title="Direct link to Keeping configuration.yaml readable">​</a></h2><p>The larger principle: <code>configuration.yaml</code> should only <em>include</em>, not
<em>define</em>. The real definitions live in <code>config/integrations/</code>, split by
domain:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># configuration.yaml</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">command_line</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token tag" style="color:rgb(255, 121, 198)">!include</span><span class="token plain"> integrations/command_line.yaml</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)"># config/integrations/command_line.yaml</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">command_line</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">sensor</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> CPU Temperature</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">unique_id</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> cpu_temperature_thermal_zone0</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">command</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"cat /sys/class/thermal/thermal_zone0/temp"</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">unit_of_measurement</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"°C"</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">value_template</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"{{ value | float / 1000 | round(1) }}"</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">scan_interval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token number">30</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">device_class</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> temperature</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token key atrule">state_class</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> measurement</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p><code>device_class: temperature</code> gives the entity proper units and graph support,
and <code>state_class: measurement</code> makes statistics and long-term history work.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="result">Result<a href="#result" class="hash-link" aria-label="Direct link to Result" title="Direct link to Result">​</a></h2><p><code>sensor.cpu_temperature</code> reports values like <code>47.2 °C</code> at idle on a Raspberry
Pi 4, and 30 seconds between samples is more than enough for a slow-moving
thermal value.</p><p>From there it can go on a dashboard, into a ventilation automation, or into a
warning when the board approaches its throttling range.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[A firmware watchdog for a BLE proxy]]></title>
        <id>https://docs.dev-eric.work/journal/2026/03/23/ble-proxy-firmware-watchdog</id>
        <link href="https://docs.dev-eric.work/journal/2026/03/23/ble-proxy-firmware-watchdog"/>
        <updated>2026-03-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[When an ESP32 Bluetooth scanner silently stopped, the fix was a watchdog that runs on the device itself.]]></summary>
        <content type="html"><![CDATA[<p>Bluetooth proxies are how Home Assistant sees BLE devices that are out of
range of the host. They are small ESP32 boards running ESPHome, and most of
the time they are invisible infrastructure. When one stops scanning, nothing
crashes: the device stays on Wi-Fi, the API answers, the logs look calm — only
the Bluetooth side goes quiet.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-symptom">The symptom<a href="#the-symptom" class="hash-link" aria-label="Direct link to The symptom" title="Direct link to The symptom">​</a></h2><p>Three proxies cover the house. One of them stopped discovering devices. From
Home Assistant's Bluetooth diagnostics:</p><ul><li><code>scanning: false</code></li><li><code>discovered devices: 0</code></li><li>no advertisement seen for about 11.5 hours</li></ul><p>The Wi-Fi connection was up, the ESPHome API was responsive, and the other two
proxies were fine. Nothing in the normal device state suggested a problem —
which is exactly why it went unnoticed for half a day.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-did-not-work">What did not work<a href="#what-did-not-work" class="hash-link" aria-label="Direct link to What did not work" title="Direct link to What did not work">​</a></h2><p>Home Assistant-side remedies only act on the integration, not on the radio:</p><ul><li>reloading the integration</li><li>disabling and re-enabling the proxy</li><li>talking to it through <code>aioesphomeapi</code></li><li>an OTA update from the command line, which timed out</li></ul><p>The scanner only came back after a compile-and-flash from the ESPHome
Dashboard over its WebSocket OTA. That pointed at the device firmware as the
layer that had to recover itself.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-a-watchdog-on-the-device">Why a watchdog on the device<a href="#why-a-watchdog-on-the-device" class="hash-link" aria-label="Direct link to Why a watchdog on the device" title="Direct link to Why a watchdog on the device">​</a></h2><p>An automation in Home Assistant could notice a dead proxy and restart it, but
it depends on Home Assistant, on the network path to the proxy, and on a
polling interval. It has no direct view of the scanner state, and the health
logic ends up spread across YAML files.</p><p>The firmware already knows the one thing that matters: when it last saw a BLE
advertisement. That makes a watchdog trivial and local.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-watchdog">The watchdog<a href="#the-watchdog" class="hash-link" aria-label="Direct link to The watchdog" title="Direct link to The watchdog">​</a></h2><p>Track the last advertisement time in a global:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">globals</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">id</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> last_ble_adv_time</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">type</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> uint32_t</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">restore_value</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> no</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">initial_value</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"0"</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">esp32_ble_tracker</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">on_ble_advertise</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">lambda</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">        id(last_ble_adv_time) = millis();</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Then check it on an interval, with a grace period so a slow boot is not
mistaken for a hang:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">interval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">interval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 2min</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">then</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">lambda</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          // Do not reboot while the scanner is still coming up.</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          if (millis() &lt; 3 * 60 * 1000) </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">            return;</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          const uint32_t last = id(last_ble_adv_time);</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          if (last == 0 </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token plain"> (millis() </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> last) </span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token plain"> 10 * 60 * 1000) </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">            App.safe_reboot();</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Ten minutes without a single advertisement is far beyond any normal quiet
period, so the reboot is safe. The first occurrence happened weeks before the
watchdog existed; since then the proxy recovers on its own.</p><p>Two small helpers make the behavior visible:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">button</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">platform</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> restart</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Restart</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">sensor</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">-</span><span class="token plain"> </span><span class="token key atrule">platform</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> uptime</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> Uptime</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token key atrule">update_interval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 60s</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The uptime sensor shows whether the watchdog is firing at all, and the restart
button allows a manual kick without opening the dashboard.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaway">Takeaway<a href="#takeaway" class="hash-link" aria-label="Direct link to Takeaway" title="Direct link to Takeaway">​</a></h2><p>Fix device health on the device. A firmware watchdog is autonomous, local, it
needs no network, it lives in one YAML file, and it reacts immediately. Home
Assistant automations are for logic that spans devices, not for keeping a
single device alive.</p><p>The same pattern applies to other silent firmware failures: Wi-Fi that drops
while the API socket stays open, sensors that start returning <code>NaN</code>, or a
stuck output that never changes state.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[From a React hook to a framework-agnostic SDK core]]></title>
        <id>https://docs.dev-eric.work/journal/2026/03/06/from-a-react-hook-to-a-framework-agnostic-sdk-core</id>
        <link href="https://docs.dev-eric.work/journal/2026/03/06/from-a-react-hook-to-a-framework-agnostic-sdk-core"/>
        <updated>2026-03-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Why a single React hook became hard to maintain at scale, and how it was extracted into a framework-agnostic TypeScript client without breaking live integrations.]]></summary>
        <content type="html"><![CDATA[<p>Every SDK starts with a trade-off. Ours was explicit: ship the fastest possible integration path, and defer the architecture until the product had customers.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-starting-point">The starting point<a href="#the-starting-point" class="hash-link" aria-label="Direct link to The starting point" title="Direct link to The starting point">​</a></h2><p>The first version of the SDK was a single React hooks package. One hook owned almost everything:</p><ul><li>API communication over REST, with hand-written request and response types</li><li>multi-chain wallet signing adapters</li><li>auth and token lifecycle</li><li>loading and error state</li><li>business logic</li></ul><p>For a small startup this is a reasonable design. It maximizes time to market and minimizes integration friction: a partner installs one package, mounts a provider, and the hook does the rest.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="where-it-started-to-hurt">Where it started to hurt<a href="#where-it-started-to-hurt" class="hash-link" aria-label="Direct link to Where it started to hurt" title="Direct link to Where it started to hurt">​</a></h2><p>Three problems appeared as the product grew.</p><p><strong>Framework lock-in.</strong> All logic was bound to the React lifecycle. Anything that was not React — a Vue or Svelte integration, a script, a background worker — could not reuse the SDK at all.</p><p><strong>Maintenance burden.</strong> Ten-plus supported chains turned the hook into a giant switch-case abstraction. Responsibilities blurred: API concerns, signing concerns and UI state lived in the same file.</p><p><strong>Testability.</strong> Testing business logic required mounting React components. That made tests slow and brittle, and it quietly discouraged coverage.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-constraints">The constraints<a href="#the-constraints" class="hash-link" aria-label="Direct link to The constraints" title="Direct link to The constraints">​</a></h2><p>The migration had to be done by one engineer, alongside normal feature delivery. Live customers were running the old architecture in production, so downtime was not an option. There was no hard deadline, but there was also no freeze: the new architecture had to coexist with the old one, and customers could adopt it at their own pace.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-options">The options<a href="#the-options" class="hash-link" aria-label="Direct link to The options" title="Direct link to The options">​</a></h2><p><strong>Split the hook into smaller hooks</strong> — <code>useAuth</code>, <code>useSigning</code>, <code>useAlerts</code>. Rejected: the logic stays React-bound, so the framework lock-in and testability problems remain. It only delays them.</p><p><strong>A framework adapter over a monolithic core</strong> — invert the dependency but keep the core shaped by one framework's needs. Rejected: every supported framework becomes another maintenance surface.</p><p><strong>A pure TypeScript client with thin framework wrappers.</strong> Chosen. The core owns the logic; React, Vue or anything else becomes a small adapter.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-extraction">The extraction<a href="#the-extraction" class="hash-link" aria-label="Direct link to The extraction" title="Direct link to The extraction">​</a></h2><p>The core became a standalone TypeScript package: a client class owning the auth state machine, wallet signing adapters, the API layer and the token lifecycle. It has no React dependency and runs anywhere JavaScript runs.</p><p>Around the same time, the communication layer moved from REST to GraphQL. Hand-maintained types were replaced by code generation against the schema. That eliminated a class of schema drift bugs and reduced cross-team coordination cost — the types could no longer disagree with the API.</p><p>The migration itself ran in three phases:</p><ol><li><strong>Coexistence.</strong> The old hooks and the new client lived side by side inside the existing React package. Customers were unaffected.</li><li><strong>New surface.</strong> A new React package was built entirely on the new core, replacing the old one.</li><li><strong>Removal.</strong> Once the last customers had migrated, the legacy packages were removed in a major version.</li></ol><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-outcome">The outcome<a href="#the-outcome" class="hash-link" aria-label="Direct link to The outcome" title="Direct link to The outcome">​</a></h2><p>The SDK became a platform core: one place for auth, signing and API logic, usable from any framework or no framework at all. The full migration took about three quarters with zero downtime. Adding a chain no longer means touching React code, which cuts regression risk.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-i-would-do-differently">What I would do differently<a href="#what-i-would-do-differently" class="hash-link" aria-label="Direct link to What I would do differently" title="Direct link to What I would do differently">​</a></h2><ul><li>Decouple earlier. The pain was predictable; waiting for it to become acute cost more than starting the extraction would have.</li><li>Treat a framework-agnostic core as a day-one principle, not a later refactor.</li><li>Define a formal deprecation policy before the first breaking change.</li><li>Run integration tests that exercise the old and new paths side by side during coexistence. Behavioural drift between them is the biggest risk in this kind of migration, and it stays invisible until a customer hits it.</li></ul><p>Migrating a live SDK is mostly a communication problem wearing an architecture costume. Phased, independently shippable steps are what make it survivable — especially when there is only one engineer.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Controlling an IR air conditioner with Zigbee2MQTT]]></title>
        <id>https://docs.dev-eric.work/journal/2026/03/04/ir-air-conditioner-via-zigbee2mqtt</id>
        <link href="https://docs.dev-eric.work/journal/2026/03/04/ir-air-conditioner-via-zigbee2mqtt"/>
        <updated>2026-03-04T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[IR is one-way and AC remotes send full state, not button presses. That shapes the whole integration.]]></summary>
        <content type="html"><![CDATA[<p>A small Zigbee IR blaster turns dumb appliances into things Home Assistant can
control — TVs, fans, air conditioners. The integration is simple on paper:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">Home Assistant → mqtt.publish → Zigbee2MQTT → IR blaster → appliance</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The interesting part is the protocol. IR has no feedback and no handshake, and
air conditioner remotes do not send commands — they send full state.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="codes-are-states-not-buttons">Codes are states, not buttons<a href="#codes-are-states-not-buttons" class="hash-link" aria-label="Direct link to Codes are states, not buttons" title="Direct link to Codes are states, not buttons">​</a></h2><p>A TV remote sends "volume up". An air conditioner remote sends the complete
state every time: power, mode, target temperature and fan speed together.
There is no "make it one degree warmer"; there is only "Cool, 26 °C, fan
auto".</p><p>That means one learned code per state you want to use, and automation logic
that picks a state instead of pressing buttons.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="learning-codes">Learning codes<a href="#learning-codes" class="hash-link" aria-label="Direct link to Learning codes" title="Direct link to Learning codes">​</a></h2><p>In the Zigbee2MQTT device page, expose and enable <strong>Learn IR code</strong>, point the
physical remote at the blaster, and press the button you want to capture. The
code lands in a sensor like <code>sensor.&lt;device&gt;_learned_ir_code</code> as a long base64
string — often over a thousand characters.</p><p>A practical set for an air conditioner:</p><ul><li><code>Auto</code></li><li><code>Cool 25</code></li><li><code>Cool 26</code></li><li><code>Off</code></li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="sending-codes">Sending codes<a href="#sending-codes" class="hash-link" aria-label="Direct link to Sending codes" title="Direct link to Sending codes">​</a></h2><p>From Home Assistant the reliable path is <code>mqtt.publish</code>:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">action</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> mqtt.publish</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">data</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">topic</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> zigbee2mqtt/&lt;friendly_name</span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token plain">/set</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">payload</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'{"ir_code_to_send": "&lt;BASE64&gt;"}'</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Three things that cost time before they are understood:</p><ol><li><strong>The topic needs the Zigbee2MQTT friendly name</strong>, which may be different
from the name Home Assistant shows for the device.</li><li><strong><code>base_topic</code> is needed when publishing from Home Assistant or
<code>mosquitto_pub</code>, but not in the Zigbee2MQTT Dev Console</strong> — the console is
already inside that topic namespace. Adding the prefix there produces
"Entity 'zigbee2mqtt' unknown".</li><li><strong>Do not use <code>text.set_value</code></strong> on the exposed IR-code text entity. It is
limited to 255 characters while the codes are much longer, and the send
fails with a generic "Unknown error".</li></ol><h2 class="anchor anchorWithStickyNavbar_LWe7" id="automation-design">Automation design<a href="#automation-design" class="hash-link" aria-label="Direct link to Automation design" title="Direct link to Automation design">​</a></h2><p>Because every code is a complete state, keep the state set small and let the
automation choose:</p><ul><li>room temperature above 28 °C → send <code>Cool 26</code></li><li>nobody home for 30 minutes → send <code>Off</code></li></ul><p>Store the long base64 strings in <code>secrets.yaml</code> and reference them, so the
automations stay readable and the codes can be updated in one place.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="debug-checklist">Debug checklist<a href="#debug-checklist" class="hash-link" aria-label="Direct link to Debug checklist" title="Direct link to Debug checklist">​</a></h2><ul><li><strong>Entity unknown</strong> → the topic or the friendly name is wrong.</li><li><strong>"Entity 'zigbee2mqtt' unknown"</strong> → remove the <code>base_topic</code> prefix in the
Dev Console.</li><li><strong>"Unknown error"</strong> → the code is too long for the text entity; use
<code>mqtt.publish</code> instead.</li></ul><p>IR stays a one-way protocol, so Home Assistant never knows whether the
appliance received the command. Design for the states you can send, and keep
the automation logic simple enough to reason about from the codes alone.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Backend-driven configuration for an embeddable SDK]]></title>
        <id>https://docs.dev-eric.work/journal/2026/02/20/backend-driven-configuration-for-an-embeddable-sdk</id>
        <link href="https://docs.dev-eric.work/journal/2026/02/20/backend-driven-configuration-for-an-embeddable-sdk"/>
        <updated>2026-02-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Letting dApp developers define interactive on-chain actions in a backend admin panel, and rendering them through an SDK without coupling the SDK to any blockchain.]]></summary>
        <content type="html"><![CDATA[<p>Most SDK features are designed in code. This one was designed so that partners could define it in an admin panel.</p><p>The feature lets dApp developers configure interactive on-chain actions through a backend UI and render them as components through the SDK. When a user clicks an action, the SDK asks the backend for a serialized transaction, hands it to the host application, and lets the host sign and submit it. The feature went from a spike in late March 2025 to production in May 2025.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-constraints">The constraints<a href="#the-constraints" class="hash-link" aria-label="Direct link to The constraints" title="Direct link to The constraints">​</a></h2><ul><li><strong>Time to market.</strong> The product team wanted a fast delivery path, which initially pushed the design toward a brand-new package.</li><li><strong>No breaking changes.</strong> Existing consumers of the client and React packages could not be affected.</li><li><strong>Blockchain-agnostic.</strong> The action-execution layer could not be tied to EVM, Solana, SUI or anything else; the SDK should return a transaction payload and let the host handle signing.</li><li><strong>A different auth model.</strong> The feature does not require a logged-in user session. It only needs <code>authParams</code> — a wallet public key and a blockchain type — to sign the activation request.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-options">The options<a href="#the-options" class="hash-link" aria-label="Direct link to The options" title="Direct link to The options">​</a></h2><p><strong>A new package.</strong> The original proposal was a separate package containing the UI components and a new client. On paper this avoids touching existing packages and gives clean separation.</p><p>The problems only show up when you draw the dependency graph:</p><ul><li>The UI needs types and the client from the existing frontend package, plus React context patterns — theme support, error views, CSS variables — that already live in the React package. It would either duplicate them or depend on the package it was trying to stay separate from.</li><li>Cross-package type imports were already fragile. One shared types package was importing <code>AuthParams</code> from the client package and had to use <code>import type</code> to avoid a circular dependency. Another package in the graph would have turned a line into a mesh.</li><li>The maintenance overhead would be real, and the isolation would be an illusion.</li></ul><p><strong>Integrate into the existing packages.</strong> Add a GraphQL query to the API package, a REST call to the dataplane package, a sibling client to the frontend package, and a context plus components to the React package.</p><p>This was the proposal I wrote up and brought to the team. The key realization: the feature is purely additive. New exports cannot break existing consumers, so the "no breaking changes" argument for a separate package was a false constraint.</p><p><strong>Decision: integrate.</strong> The <code>no breaking changes</code> risk was not real, and the code would immediately want to cross the package boundary anyway. The accepted trade-off is a slightly larger React package, mitigated by tree-shaking — consumers who do not import the feature don't pay for it.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="design-decisions">Design decisions<a href="#design-decisions" class="hash-link" aria-label="Direct link to Design decisions" title="Direct link to Design decisions">​</a></h2><p><strong>A sibling client, not a subclass.</strong> The feature has a fundamentally different auth model: no persistent session, no storage. Making it a subclass of the main client would have forced it to inherit behaviour it must not have. It became a sibling with a much lighter configuration:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">type</span><span class="token plain"> </span><span class="token class-name">LinkClientConfig</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  env</span><span class="token operator">?</span><span class="token operator">:</span><span class="token plain"> Environment</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  authParams</span><span class="token operator">:</span><span class="token plain"> AuthParams</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p><strong>The SDK never touches the wallet.</strong> The backend returns a serialized transaction; the SDK passes it to the host through an <code>actionHandler</code> callback. The host signs and submits. This is what keeps the feature blockchain-agnostic at the API boundary.</p><p><strong>Reuse the existing UI infrastructure.</strong> Theme support, error views, CSS variables and the established <code>classNames</code> override pattern all came from the React package instead of being reimplemented.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-it-works">How it works<a href="#how-it-works" class="hash-link" aria-label="Direct link to How it works" title="Direct link to How it works">​</a></h2><p>The feature is split across four layers:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">api package      → a GraphQL query for the link configuration</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">dataplane package → a REST call to activate an action</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">frontend package  → models + a client + a factory function</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">react package     → a context provider + components + input widgets</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The data flow:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">User visits the link URL</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → provider initializes the link client</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → component mounts and fetches the config</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      → GraphQL returns the raw config as a JSON string</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      → JSON.parse + a type guard validate it</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → context stores the config per link id</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → action state is initialized with default inputs per action</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">User fills in inputs and clicks an action</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → the action validates that the blockchain type matches the config</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → the client POSTs { actionId, authParams, inputs } to the dataplane</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → the response carries transactions plus success/failure messages</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → actionHandler(payload) — the host signs and submits</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Three details worth copying:</p><p><strong>Dictionaries keyed by id.</strong> The context holds configs keyed by link id and action state keyed by <code>${linkId}:${actionId}</code>. A single provider can serve multiple components on the same page without refetching anything.</p><p><strong>Dual service injection.</strong> Config fetch is a GraphQL read (cacheable, tenant-level). Action execution is a REST write (user-specific, requires auth params). The client takes both services explicitly rather than hiding the difference.</p><p><strong>A <code>preAction</code> prop.</strong> The component accepts an optional pre-action with <code>disabled</code>, <code>label</code> and <code>onClick</code>. This lets the host gate execution behind a wallet-connection step without the SDK knowing anything about wallet state. If it is omitted, the action button executes directly.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="outcome">Outcome<a href="#outcome" class="hash-link" aria-label="Direct link to Outcome" title="Direct link to Outcome">​</a></h2><p>The feature shipped to production in May 2025 with zero breaking changes. The cross-package circular dependency was avoided with type-only imports, and a component test covered the new UI. One post-launch fix was needed: the dataplane endpoint path was case-sensitive and the deployment had it capitalized; the client was corrected to match.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-i-took-away">What I took away<a href="#what-i-took-away" class="hash-link" aria-label="Direct link to What I took away" title="Direct link to What I took away">​</a></h2><ul><li>For an additive feature, "we might break something" is rarely a reason to create a new package. New exports are safe by construction.</li><li>Draw the dependency graph before choosing the boundaries. A package that must depend on the thing it is supposed to be isolated from is just indirection.</li><li>Two different auth models deserve two sibling clients, not inheritance.</li><li>Letting the host sign the transaction is what makes a feature blockchain-agnostic. That is an API-boundary decision, not a refactor.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Designing unified error handling for a public SDK]]></title>
        <id>https://docs.dev-eric.work/journal/2026/02/11/designing-unified-error-handling-for-a-public-sdk</id>
        <link href="https://docs.dev-eric.work/journal/2026/02/11/designing-unified-error-handling-for-a-public-sdk"/>
        <updated>2026-02-11T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A GraphQL API has two different error channels. An SDK that only surfaces one of them is lying by omission — here is how we unified both behind a single error type.]]></summary>
        <content type="html"><![CDATA[<p>An SDK's error behaviour is part of its public API. Every caller has to make a decision about errors — handle them, retry them, or ignore them — and they can only make that decision if the error they receive is honest about what happened.</p><p>Ours was not honest. The backend had two ways of reporting failure, and the client only handled one of them.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="two-error-channels">Two error channels<a href="#two-error-channels" class="hash-link" aria-label="Direct link to Two error channels" title="Direct link to Two error channels">​</a></h2><p>GraphQL gives an API two distinct places to put an error.</p><p><strong>Protocol errors</strong> live in the top-level <code>errors</code> array:</p><div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"errors"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"message"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"The current user is not authorized to access this resource."</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"extensions"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"code"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"AUTH_NOT_AUTHENTICATED"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"data"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token property">"alert"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token null keyword" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>These are system-level failures: missing authentication, broken requests, legacy operations. Our HTTP client already converted them into a generic <code>ClientError</code>, so this channel worked.</p><p><strong>Payload errors</strong> live inside the response data:</p><div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"errors"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token null keyword" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token property">"data"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token property">"createSlackChannelTarget"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"slackChannelTarget"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token null keyword" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token property">"errors"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">"__typename"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"TargetLimitExceededError"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">          </span><span class="token property">"message"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"You have reached the maximum number of targets."</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>This channel carries business-rule failures: quota limits, missing targets, invalid arguments. It is type-safe by design — the <code>__typename</code> tells you exactly which error you got.</p><p>And it was largely ignored. Mutation methods returned the payload to the caller and left the <code>errors</code> array for them to notice. Some did not even use the pattern yet. The result was an API surface where the same failure could be a rejection, a silent no-op, or a null field depending on which mutation you called.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-good-looks-like">What good looks like<a href="#what-good-looks-like" class="hash-link" aria-label="Direct link to What good looks like" title="Direct link to What good looks like">​</a></h2><p>We wanted three properties:</p><ul><li><strong>One type to check.</strong> Callers should be able to ask "is this an authentication error?" without string matching a message.</li><li><strong>No lost context.</strong> The original <code>__typename</code>, the message and the underlying cause should all survive the conversion.</li><li><strong>No big-bang break.</strong> Existing throws had to keep working while new code adopted the new type.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-design">The design<a href="#the-design" class="hash-link" aria-label="Direct link to The design" title="Direct link to The design">​</a></h2><p>We added an <code>errors/</code> module to the client package rather than replacing the existing error handling. At its centre is a base class and a factory:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">export</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">class</span><span class="token plain"> </span><span class="token class-name">SdkError</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">extends</span><span class="token plain"> </span><span class="token class-name">Error</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">readonly</span><span class="token plain"> errorType</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">readonly</span><span class="token plain"> code</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">readonly</span><span class="token plain"> cause</span><span class="token operator">?</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">unknown</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">static</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">from</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">unknown</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token operator">:</span><span class="token plain"> SdkError </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">instanceof</span><span class="token plain"> </span><span class="token class-name">SdkError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token function" style="color:rgb(80, 250, 123)">isPayloadError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">fromPayloadError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">instanceof</span><span class="token plain"> </span><span class="token class-name">Error</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token plain"> </span><span class="token class-name">SdkUnknownError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">message</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'UNKNOWN'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token plain"> </span><span class="token class-name">SdkUnknownError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token function" style="color:rgb(80, 250, 123)">String</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'UNKNOWN'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The factory is the key detail. Instead of asking every call site to know which class to construct, they all funnel through <code>SdkError.from(...)</code>. It is idempotent, it accepts anything, and it never throws while trying to describe a throw.</p><p>A type guard identifies payload errors by their shape:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">isPayloadError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  e</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">unknown</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token operator">:</span><span class="token plain"> e </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">is</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> __typename</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> message</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">typeof</span><span class="token plain"> e </span><span class="token operator">===</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'object'</span><span class="token plain"> </span><span class="token operator">&amp;&amp;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    e </span><span class="token operator">!==</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token plain"> </span><span class="token operator">&amp;&amp;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">'__typename'</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">in</span><span class="token plain"> e </span><span class="token operator">&amp;&amp;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token string" style="color:rgb(255, 121, 198)">'message'</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">in</span><span class="token plain"> e</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Then a switch maps error families onto subclasses:</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">TargetLimitExceededError, TargetDoesNotExistError, Web3TargetNotFoundError, ...</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → SdkTargetError (TARGET)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">UnauthorizedAccessError</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → SdkAuthenticationError (AUTHENTICATION)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">ArgumentError, ArgumentOutOfRangeError, ArgumentNullError</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → SdkValidationError (VALIDATION)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">everything else</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  → SdkUnknownError (UNKNOWN)</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Each subclass carries an <code>errorType</code> category, the original backend <code>code</code>, a timestamp and the <code>cause</code>. Consumers can now branch on category instead of probing messages.</p><p>At the call sites, mutations validate their payload before returning:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> mutation </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">this</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">service</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">createWebPushTarget</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">input</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> errors </span><span class="token operator">=</span><span class="token plain"> mutation</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">createWebPushTarget</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">errors</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">errors </span><span class="token operator">&amp;&amp;</span><span class="token plain"> errors</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">length </span><span class="token operator">&gt;</span><span class="token plain"> </span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">throw</span><span class="token plain"> SdkError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">from</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">errors</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> mutation</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>In the React layer, unsafe casts disappeared:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">// before</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">catch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">as</span><span class="token plain"> Error</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">// after</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">catch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">SdkError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">from</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">e</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-unglamorous-part">The unglamorous part<a href="#the-unglamorous-part" class="hash-link" aria-label="Direct link to The unglamorous part" title="Direct link to The unglamorous part">​</a></h2><p>The interesting engineering was not the class hierarchy. It was the schema archaeology needed to make the hierarchy complete.</p><p>Payload errors only exist if the schema declares them, and it did not declare them consistently. We catalogued every mutation the SDK consumed, recorded which ones implemented the pattern, and then added the missing error fragments to the schema and type generation. Only after the types were honest could the runtime be.</p><p>A few operations also turned out to return protocol errors where the schema promised payload errors. The abstraction had to tolerate both — which the <code>from()</code> factory does by construction.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-deliberate-breaking-change">The deliberate breaking change<a href="#the-deliberate-breaking-change" class="hash-link" aria-label="Direct link to The deliberate breaking change" title="Direct link to The deliberate breaking change">​</a></h2><p>The migration shipped in a major release, because it changed observable behaviour: mutations that previously swallowed payload errors now throw them. The release notes called it out explicitly, with a migration example:</p><div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">try</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> client</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token function" style="color:rgb(80, 250, 123)">deleteAlerts</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> ids</span><span class="token operator">:</span><span class="token plain"> alertIds </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">catch</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">error</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">error </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">instanceof</span><span class="token plain"> </span><span class="token class-name">SdkValidationError</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token comment" style="color:rgb(98, 114, 164)">// handle validation error</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>That is the honest version. The previous behaviour — accepting an empty ID list and returning as if it did something — was the actual bug.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="why-categories-matter">Why categories matter<a href="#why-categories-matter" class="hash-link" aria-label="Direct link to Why categories matter" title="Direct link to Why categories matter">​</a></h2><p>Unified errors are not just nicer to catch. They make automations possible.</p><p>We wanted to wire an on-call paging tool into the SDK's CI/CD pipeline, but paging a human on every expected failure is worse than no paging at all. Categorised errors let the pipeline ignore known conditions such as rate limits while still escalating genuine faults. Without the abstraction, that classification would have been string matching on messages — which breaks the first time a message is reworded.</p><p>If an SDK reports failures in two ways, callers will handle one of them and ignore the other. Collapsing both into a typed, categorised error is the smallest change that makes the API tell the truth.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Migrating a cluster to GitOps with Flux and SOPS]]></title>
        <id>https://docs.dev-eric.work/journal/2026/02/07/migrating-a-cluster-to-gitops-with-flux-and-sops</id>
        <link href="https://docs.dev-eric.work/journal/2026/02/07/migrating-a-cluster-to-gitops-with-flux-and-sops"/>
        <updated>2026-02-07T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Replacing ad-hoc kubectl and Helm apply with a Git repository as the source of truth, including encrypted secrets.]]></summary>
        <content type="html"><![CDATA[<p>The cluster ran on <code>kubectl apply</code>, one-off Helm commands and a handful of
scripts for long enough that nobody could say what the actual state was. The
manifests existed somewhere, secrets lived in shell history and password
managers, and rolling something back meant remembering what it looked like
before. This is how the cluster moved to a Git repository as its source of
truth.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-goal">The goal<a href="#the-goal" class="hash-link" aria-label="Direct link to The goal" title="Direct link to The goal">​</a></h2><p>After the migration: the cluster is the output of a repository. A change is a
commit, reviewable, revertible, and reconciled automatically. Manual <code>kubectl
apply</code> is the exception, not the workflow.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="tooling">Tooling<a href="#tooling" class="hash-link" aria-label="Direct link to Tooling" title="Direct link to Tooling">​</a></h2><ul><li><strong>Flux CD</strong> as the GitOps controller, bootstrapped into the cluster.</li><li><strong>SOPS</strong> with <strong>age</strong> for encrypting secrets in the repository.</li><li><code>flux check --pre</code> before bootstrapping, to validate the cluster meets the
prerequisites.</li></ul><p>The bootstrap installs the Flux controllers and wires them to the repository,
after which everything else arrives through reconciliation.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="repository-layout">Repository layout<a href="#repository-layout" class="hash-link" aria-label="Direct link to Repository layout" title="Direct link to Repository layout">​</a></h2><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">clusters/production/     # flux-system + one Kustomization per app</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">apps/&lt;app&gt;/base/         # manifests for a single app</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">apps/&lt;app&gt;/kustomization.yaml</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">apps/&lt;app&gt;/secrets.enc.yaml</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">infrastructure/namespaces/</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">scripts/</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">docs/</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The <code>clusters/</code> directory describes <strong>what should exist in the cluster</strong>;
<code>apps/</code> describes <strong>what each thing is</strong>; <code>infrastructure/</code> holds shared
prerequisites like namespaces. Splitting them matters because an app's
manifests should be movable without changing how the cluster consumes them.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-reconciliation-is-wired">How reconciliation is wired<a href="#how-reconciliation-is-wired" class="hash-link" aria-label="Direct link to How reconciliation is wired" title="Direct link to How reconciliation is wired">​</a></h2><p>Each app gets its own Flux <code>Kustomization</code> resource that points at its
directory:</p><ul><li><code>interval: 10m</code> — how often the repository is compared to the cluster.</li><li><code>retryInterval: 2m</code>, <code>timeout: 5m</code> — bounded retries for a broken apply.</li><li><code>prune: true</code> — resources removed from Git are removed from the cluster.</li><li><code>wait: true</code> plus health checks — reconciliation is not "done" until the
resources are actually healthy.</li><li><code>decryption.provider: sops</code> — secrets are decrypted in-cluster at apply
time.</li></ul><p>Ten minutes sounds slow when iterating, and it is. For a home cluster the
trade is fine: drift gets corrected without anyone watching, and a bad commit
is undone with <code>git revert</code> instead of a manual cleanup.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="secrets">Secrets<a href="#secrets" class="hash-link" aria-label="Direct link to Secrets" title="Direct link to Secrets">​</a></h2><p>Secrets are committed encrypted. SOPS is configured with an age key pair:
the public key is used to encrypt, the private key never enters Git. The
private key is backed up offline, and the cluster receives it once as a
<code>sops-age</code> secret in the <code>flux-system</code> namespace so the controllers can
decrypt at apply time.</p><p>The practical benefit is reviewability: an encrypted diff still shows which
keys changed, so a secret rotation is visible in a pull request without ever
exposing the value.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="proving-it-with-a-pilot-app">Proving it with a pilot app<a href="#proving-it-with-a-pilot-app" class="hash-link" aria-label="Direct link to Proving it with a pilot app" title="Direct link to Proving it with a pilot app">​</a></h2><p>The migration was validated with a single representative app — a web
application with a database, an encrypted secret, persistent storage and an
ingress. Once that app reconciled end to end, the pattern was repeated for
the rest.</p><p>Verification steps that were worth formalizing:</p><ul><li><code>flux get kustomizations</code> shows <code>Ready</code> and <code>Applied revision</code>.</li><li><code>kubectl get all -n &lt;app&gt;</code> matches the repository.</li><li>An intentional annotation change in Git shows up in the cluster.</li><li><code>git revert</code> of that change rolls it back without manual intervention.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-stayed-manual">What stayed manual<a href="#what-stayed-manual" class="hash-link" aria-label="Direct link to What stayed manual" title="Direct link to What stayed manual">​</a></h2><p>Node-level configuration (the kubelet and datastore settings on each
control-plane node) is not part of this repository. GitOps covers workloads
and their configuration, not the machines running them. That boundary is
worth writing down so it is not mistaken for drift.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaways">Takeaways<a href="#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways">​</a></h2><ul><li>The win is not automation for its own sake; it is that the intended state
is written down once and reviewed like code.</li><li>Encrypted secrets in Git are workable when the key management is explicit
and the private key is backed up somewhere that is not the repository.</li><li>A pilot app is enough to prove the layout before migrating everything.</li><li>Keep a rollback path (<code>git revert</code>) and test it early, while the change is
still small.</li></ul>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Tailscale subnet routers and asymmetric routing on the home LAN]]></title>
        <id>https://docs.dev-eric.work/journal/2026/02/06/tailscale-subnet-router-asymmetric-routing</id>
        <link href="https://docs.dev-eric.work/journal/2026/02/06/tailscale-subnet-router-asymmetric-routing"/>
        <updated>2026-02-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A LAN host became unreachable from its own network while still reachable over Tailscale. The cause was a subnet router advertising the very subnet it lived on.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-symptom">The symptom<a href="#the-symptom" class="hash-link" aria-label="Direct link to The symptom" title="Direct link to The symptom">​</a></h2><p>A host on the home LAN became unreachable from other LAN devices. Every ping
and connection attempt failed, while the same host answered normally over its
Tailscale address. The router logged ICMP redirects toward the affected host
during the failures, which pointed at a routing disagreement rather than a
broken interface.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="how-tailscale-subnet-routers-work">How Tailscale subnet routers work<a href="#how-tailscale-subnet-routers-work" class="hash-link" aria-label="Direct link to How Tailscale subnet routers work" title="Direct link to How Tailscale subnet routers work">​</a></h2><p>A subnet router advertises routes for a physical network into the tailnet, so
remote clients can reach that network without running Tailscale on every
device. Nodes that accept those routes install them in a separate routing
table and add a policy rule that sends matching traffic through the Tailscale
interface instead of the default route.</p><p>That design is what makes subnet routing convenient — and what makes overlaps
dangerous.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="root-cause">Root cause<a href="#root-cause" class="hash-link" aria-label="Direct link to Root cause" title="Direct link to Root cause">​</a></h2><p>One host on the LAN was running Tailscale as a subnet router for the same
subnet it was connected to, with route acceptance enabled. Two facts combined:</p><ul><li>Inbound traffic reached the host over the LAN, as expected.</li><li>Replies to that traffic matched the accepted-route rule and left through the
Tailscale interface.</li></ul><p>The return path no longer matched the request path. Requests arrived over
Ethernet, replies departed over the VPN, and the peer discarded the replies
because they never came back the way they went out. This is asymmetric
routing: every interface is up, every route looks plausible in isolation, and
traffic still disappears.</p><p>The detail that makes this nasty is that the host is the only one affected.
Other LAN devices route to it normally; the problem lives entirely in its
policy routing rules.</p><p>A useful contrast is how different systems ship: some appliance operating
systems install a high-priority rule that keeps local-network destinations in
the main routing table by default, while a plain general-purpose Linux install
does not. The same network, the same Tailscale settings, different failure
behavior — the protection rule is the difference.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-fix">The fix<a href="#the-fix" class="hash-link" aria-label="Direct link to The fix" title="Direct link to The fix">​</a></h2><p>Keep traffic destined for the local subnet in the main routing table, with a
priority higher than the accepted-routes rule:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">ip</span><span class="token plain"> rule </span><span class="token function" style="color:rgb(80, 250, 123)">add</span><span class="token plain"> from all to </span><span class="token operator">&lt;</span><span class="token plain">lan-subnet</span><span class="token operator">&gt;</span><span class="token plain"> table main priority </span><span class="token number">5000</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>To survive reboots, persist it as a small systemd unit:</p><div class="language-ini codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-ini codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">[Unit]</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">Description=Keep local subnet traffic in the main routing table</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">After=network-online.target tailscaled.service</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">[Service]</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">Type=oneshot</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">ExecStart=/usr/sbin/ip rule add from all to &lt;lan-subnet&gt; table main priority 5000</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">RemainAfterExit=yes</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">[Install]</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">WantedBy=multi-user.target</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Verify by checking which table wins for a lookup toward another LAN host:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token function" style="color:rgb(80, 250, 123)">ip</span><span class="token plain"> rule show</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token function" style="color:rgb(80, 250, 123)">ip</span><span class="token plain"> route get </span><span class="token operator">&lt;</span><span class="token plain">lan-host</span><span class="token operator">&gt;</span><span class="token plain"> from </span><span class="token operator">&lt;</span><span class="token plain">affected-host</span><span class="token operator">&gt;</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Before the fix, the lookup resolves through the Tailscale table; after it, the
main table matches first.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="policy-routing-priorities">Policy routing priorities<a href="#policy-routing-priorities" class="hash-link" aria-label="Direct link to Policy routing priorities" title="Direct link to Policy routing priorities">​</a></h2><p>Linux evaluates policy routing rules in ascending priority order, and the
first match wins. The relevant neighborhood on a Tailscale node looks like
this:</p><table><thead><tr><th>Priority</th><th>Rule</th><th>Purpose</th></tr></thead><tbody><tr><td>0</td><td>local</td><td>local addresses</td></tr><tr><td>5000</td><td>local-network protection</td><td>keeps LAN destinations in the main table</td></tr><tr><td>5210</td><td>fwmark</td><td>Tailscale's own marked traffic</td></tr><tr><td>5270</td><td>table 52</td><td>routes accepted from subnet routers</td></tr><tr><td>32766</td><td>main</td><td>normal routes</td></tr><tr><td>32767</td><td>default</td><td>fallback</td></tr></tbody></table><p>The protection rule has to sit between the local rules and the accepted-routes
rule. Priority 5000 is the conventional choice because it is high enough to
beat 5270 but low enough to leave Tailscale's own marked traffic alone.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="best-practices">Best practices<a href="#best-practices" class="hash-link" aria-label="Direct link to Best practices" title="Direct link to Best practices">​</a></h2><ul><li>Do not advertise a subnet from a subnet router that lives inside that same
subnet.</li><li>If the overlap is unavoidable, add the protection rule to every node on the
subnet, not only to the one that failed.</li><li>Treat ICMP redirects from the router as a routing smell worth investigating.</li><li>Infrastructure devices that do not need remote subnet access should not
accept routes at all.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="appendix-what-disabling-route-acceptance-does-and-does-not-do">Appendix: what disabling route acceptance does and does not do<a href="#appendix-what-disabling-route-acceptance-does-and-does-not-do" class="hash-link" aria-label="Direct link to Appendix: what disabling route acceptance does and does not do" title="Direct link to Appendix: what disabling route acceptance does and does not do">​</a></h2><p>Turning off route acceptance stops a node from installing routes advertised by
other subnet routers. It does not block inbound connections to that node.</p><p>The direction matters. If a device does not need to reach remote networks
through the tailnet, disabling route acceptance removes the outbound
table-52 path and is the simplest prevention. If the device must accept
routes, the protection rule above keeps its replies on the LAN without giving
up access to remote subnets. The two scenarios:</p><ul><li>No route acceptance: no table-52 entries, no asymmetric path, no protection
rule needed.</li><li>Route acceptance: table-52 entries exist; add the protection rule so LAN
traffic still prefers the main table.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaway">Takeaway<a href="#takeaway" class="hash-link" aria-label="Direct link to Takeaway" title="Direct link to Takeaway">​</a></h2><p>When a host is unreachable from its own network but fine over Tailscale,
suspect policy routing asymmetry before replacing hardware: an overlap between
an advertised subnet and a local interface is enough to black-hole traffic
while every status indicator stays green.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[When a DNS replica fails silently]]></title>
        <id>https://docs.dev-eric.work/journal/2026/02/06/when-a-dns-replica-fails-silently</id>
        <link href="https://docs.dev-eric.work/journal/2026/02/06/when-a-dns-replica-fails-silently"/>
        <updated>2026-02-06T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A redundant resolver was offline for a month, the sync job had been failing for longer, and the notification path was broken too. Nobody noticed.]]></summary>
        <content type="html"><![CDATA[<p>The local network runs two DNS resolvers — a primary and a replica — kept in
sync so that either can answer queries. That is the shape of high
availability. What an audit found was that the replica had been offline for
about a month, the primary had been answering everything alone, and nothing
had said a word.</p><p>It kept going: the sync container's health check had failed tens of thousands
of times in a row, and the webhook that was supposed to report sync failures
had been returning 404 for longer still. Three independent problems, stacked
so neatly that each one hid the next.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-layers-that-failed">The layers that failed<a href="#the-layers-that-failed" class="hash-link" aria-label="Direct link to The layers that failed" title="Direct link to The layers that failed">​</a></h2><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">replica node down      → resolution still works (primary answers)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">sync job failing       → no visible symptom (replica not serving)</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">notification broken    → failure report goes nowhere (webhook 404)</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Any one of these being healthy would have surfaced the others. The
redundancy worked so well that the failure was invisible.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="what-actual-ha-requires">What actual HA requires<a href="#what-actual-ha-requires" class="hash-link" aria-label="Direct link to What actual HA requires" title="Direct link to What actual HA requires">​</a></h2><ul><li><strong>Monitor the replica, not the service.</strong> "DNS resolves" only proves that
<em>a</em> resolver is up. A replica check has to ask the replica directly — a
query against its own address, not the shared name.</li><li><strong>Check the alert delivery path.</strong> A notification channel is a dependency
like any other. Stale webhooks, expired tokens and changed URLs all fail
silently unless something tests them. A periodic test alert or a dead man's
switch turns "nobody was told" into a detectable condition.</li><li><strong>A failing health check must reach a human.</strong> Repeatedly failing health
checks that only appear in container logs are decoration.</li><li><strong>Test failover.</strong> Until the replica has actually served queries while the
primary was down, "HA" is a hope. A planned failover test is the only proof.</li><li><strong>Make rejoining automatic.</strong> Firewall rules and reconnect configuration
have to survive a reboot, or a restarted replica stays offline — which is
exactly how a one-day outage becomes a month.</li></ul><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-uncomfortable-takeaway">The uncomfortable takeaway<a href="#the-uncomfortable-takeaway" class="hash-link" aria-label="Direct link to The uncomfortable takeaway" title="Direct link to The uncomfortable takeaway">​</a></h2><p>The system did not fail because DNS stopped working. It failed because the
redundancy was never exercised, and the reporting chain had the same blind
spot as the thing it reported on. Redundancy without a test is just a second
copy of the same assumption.</p>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Alerting on a small Kubernetes cluster]]></title>
        <id>https://docs.dev-eric.work/journal/2026/01/31/alerting-on-a-small-kubernetes-cluster</id>
        <link href="https://docs.dev-eric.work/journal/2026/01/31/alerting-on-a-small-kubernetes-cluster"/>
        <updated>2026-01-31T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Prometheus decides that something is wrong; Alertmanager decides whether anyone hears about it. The second part deserves as much design attention as the first.]]></summary>
        <content type="html"><![CDATA[<p>A monitoring stack usually gets installed for its dashboards and then trusted
for its alerts. But an alert is a pipeline: a rule evaluates, a state
changes, a notification is routed, a person is interrupted. Every stage can
fail quietly. This is what that pipeline looks like in a small cluster and
where the sharp edges are.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="two-systems-two-jobs">Two systems, two jobs<a href="#two-systems-two-jobs" class="hash-link" aria-label="Direct link to Two systems, two jobs" title="Direct link to Two systems, two jobs">​</a></h2><ul><li><strong>Prometheus</strong> scrapes metrics, evaluates rules, and decides when an alert
is firing.</li><li><strong>Alertmanager</strong> receives firing alerts and decides how to group, route,
inhibit and deliver them.</li></ul><p>Keeping the split in mind prevents a common confusion: a rule that never fires
and a notification that never arrives are different problems in different
systems.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-life-of-an-alert">The life of an alert<a href="#the-life-of-an-alert" class="hash-link" aria-label="Direct link to The life of an alert" title="Direct link to The life of an alert">​</a></h2><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">Inactive → Pending → Firing</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>A rule's expression becomes true, the alert enters <code>Pending</code>, and it stays
there until it has been continuously true for the rule's <code>for</code> duration. Only
then does it become <code>Firing</code> and get sent to Alertmanager. The <code>for</code> window is
the difference between "this spiked for one scrape" and "this is broken".</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="where-the-rules-come-from">Where the rules come from<a href="#where-the-rules-come-from" class="hash-link" aria-label="Direct link to Where the rules come from" title="Direct link to Where the rules come from">​</a></h2><p>The kube-prometheus-stack chart ships rule packs that can be toggled on and
off: application-level rules, node rules, and so on. One toggle is worth
calling out — datastore rules are typically disabled by default because
managed Kubernetes distributions own the datastore, and enabling the rules
without the matching metrics only produces confusion.</p><p>To see what is actually installed:</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get prometheusrules</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get prometheusrule </span><span class="token operator">&lt;</span><span class="token plain">name</span><span class="token operator">&gt;</span><span class="token plain"> -o yaml</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The object YAML is the ground truth: the expression, the threshold and the
<code>for</code> duration. Reading the live state is the Prometheus UI's <strong>Alerts</strong>
page, which shows each rule as inactive, pending or firing with the current
value.</p><p>A representative rule looks like this — a pod stuck in a crash loop:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">alert</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> KubePodCrashLooping</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">expr</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> max_over_time(kube_pod_container_status_waiting_reason</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain">reason="CrashLoopBackOff"</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">5m</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain">) </span><span class="token punctuation" style="color:rgb(248, 248, 242)">&gt;</span><span class="token plain">= 1</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">for</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> 15m</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">labels</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">severity</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> warning</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><h2 class="anchor anchorWithStickyNavbar_LWe7" id="know-which-metrics-you-actually-have">Know which metrics you actually have<a href="#know-which-metrics-you-actually-have" class="hash-link" aria-label="Direct link to Know which metrics you actually have" title="Direct link to Know which metrics you actually have">​</a></h2><p>Two exporters carry very different information:</p><ul><li><strong>kube-state-metrics</strong> reports the state of API objects: how many replicas a
deployment wants, whether a pod is waiting, and why.</li><li><strong>cAdvisor</strong> reports container resource usage: CPU, memory, filesystem.</li></ul><p>If cAdvisor is dropped to save resources — a reasonable choice on weak
hardware — the object-level rules keep working, but every rule that depends on
container memory or CPU silently has no data. Nothing breaks; the alert just
never fires. Worth writing down at install time.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="grouping-timing-and-silencing">Grouping, timing and silencing<a href="#grouping-timing-and-silencing" class="hash-link" aria-label="Direct link to Grouping, timing and silencing" title="Direct link to Grouping, timing and silencing">​</a></h2><p>Alertmanager's routing tree decides where alerts go; grouping decides how many
messages a person receives. A crash-looping app with a bad replica count can
produce several alerts at once, and <code>group_by</code> collapses them:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">group_by</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">namespace</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> alertname</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> severity</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>Group too little and the phone buzzes per pod. Group too much and unrelated
problems arrive glued together. The timing knobs are:</p><ul><li><code>group_wait</code> — how long to collect alerts before sending the first message.</li><li><code>group_interval</code> — how often to send updates about an existing group.</li><li><code>repeat_interval</code> — how often a still-firing alert is repeated.</li></ul><p><strong>Silences</strong> are the maintenance tool: a time-boxed mute that expires on its
own. They are better than editing rules for planned work, but a silence that
is too broad hides new problems inside its scope.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="the-blind-spot-whitebox-without-blackbox">The blind spot: whitebox without blackbox<a href="#the-blind-spot-whitebox-without-blackbox" class="hash-link" aria-label="Direct link to The blind spot: whitebox without blackbox" title="Direct link to The blind spot: whitebox without blackbox">​</a></h2><p>Most of this stack is <strong>whitebox</strong> monitoring — it reports from inside the
system. Pods say they are running, services say they exist. What it cannot
tell you is whether a user can reach anything.</p><p>The classic gap: the ingress is broken, every pod is healthy, and Prometheus
is happy. The cluster is "green" and the site is down.</p><p><strong>Blackbox</strong> probing closes this: probe the important endpoints from outside
on a schedule and alert when the response is wrong. If only one thing is added
after the initial setup, this is the one with the highest return.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="recording-rules">Recording rules<a href="#recording-rules" class="hash-link" aria-label="Direct link to Recording rules" title="Direct link to Recording rules">​</a></h2><p>On a small cluster, dashboards that recompute expensive expressions on every
load can cost more than the monitoring is worth. Recording rules precompute an
expression into a new metric:</p><div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_biex"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar"><code class="codeBlockLines_e6Vv"><span class="token-line" style="color:#F8F8F2"><span class="token key atrule">record</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> job</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">request_rate</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain">5m</span><br></span><span class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">expr</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> sum(rate(http_requests_total</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">5m</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain">)) by (job)</span><br></span></code></pre><div class="buttonGroup__atx"><button type="button" aria-label="Copy code to clipboard" title="Copy" class="clean-btn"><span class="copyButtonIcons_eSgA" aria-hidden="true"><svg class="copyButtonIcon_y97N" viewBox="0 0 24 24"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg><svg class="copyButtonSuccessIcon_LjdS" viewBox="0 0 24 24"><path d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z"></path></svg></span></button></div></div></div><p>The dashboard then reads a cheap series. This matters most on low-power
hardware, where a heavy query is competing with the workload it observes.</p><h2 class="anchor anchorWithStickyNavbar_LWe7" id="takeaways">Takeaways<a href="#takeaways" class="hash-link" aria-label="Direct link to Takeaways" title="Direct link to Takeaways">​</a></h2><ul><li>A firing rule and a delivered notification are separate systems; design and
test both.</li><li>Know which metrics are absent before trusting an alert to cover something.</li><li>Use <code>for</code> durations to filter noise, and group intentionally.</li><li>Whitebox monitoring cannot see broken entry paths. Add a blackbox probe.</li><li>On weak hardware, recording rules are a performance feature, not a luxury.</li></ul>]]></content>
    </entry>
</feed>