<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>Holden Salomon</title>
    <subtitle>Software and systems engineer. Projects and write-ups on Rust services, Linux virtualization, computer vision, self-hosted infrastructure and AI coding-agent tooling.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://arch.fyi/blog/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://arch.fyi/blog"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-09-21T00:00:00+00:00</updated>
    <id>https://arch.fyi/blog/atom.xml</id>
    <entry xml:lang="en">
        <title>The worst agent incidents are the ones nobody narrates</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/ai-incidents/"/>
        <id>https://arch.fyi/blog/posts/ai-incidents/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/ai-incidents/">&lt;p&gt;Every mistake a coding agent makes ends up in its transcript: the secret it printed, the file it deleted, the afternoon it spent blaming a library for its own bug. Nobody rereads transcripts, so the lessons get lost with them. ai-incidents is a scheduled agent that reads my own transcripts, judges which moments were real incidents, and files them in a ledger ranked by severity, along with a record of what it chose not to file. Since July the ledger has grown to 47 entries, 32 of them filed by the sweep itself.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Once a week the sweep reads the new transcripts on the machine. If nothing looks like an incident, it stops without loading a model. Otherwise a judge model reads the candidates alongside the existing ledger and adds each confirmed incident to the top:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;## &amp;lt;short title&amp;gt; · &amp;lt;YYYY-MM-DD&amp;gt; · &amp;lt;HIGH|MEDIUM|LOW&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;- **What:** &amp;lt;one sentence — what the AI did&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;- **Cost:** &amp;lt;one sentence — what it actually cost&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;- **Lesson:** &amp;lt;one actionable sentence — how to not repeat it&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bar is a real cost: data loss, an outage, a leaked secret, a destructive action, a debug spiral, the wrong tool, or spend nobody was metering. Routine iteration doesn’t count, and neither does an agent catching its own mistake before it cost anything. Each run also writes a dated report listing every excluded candidate with a one-line reason, so the bar stays visible and arguable. I get a push notification only when something was filed.&lt;/p&gt;
&lt;p&gt;I looked for prior art and didn’t find anything else that does this.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/ai-incidents-sweep.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/ai-incidents-sweep.svg&quot; alt=&quot;Flowchart. Agent transcripts feed a scanner that looks at what was said, what was run and what broke. Transcripts with nothing in them are marked seen. Those with candidates are staged as pending. A gate checks for candidates. If there are none, no model is loaded. If there are, a judge model reads them along with the existing ledger and writes a dated report of what it filed and excluded. A publish script with one repo and a verified remote commits it, and only on success are the pending transcripts promoted to seen. Only transcripts marked seen become eligible for the transcript reaper.&quot; width=&quot;537&quot; height=&quot;1058&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;A transcript only becomes deletable after its candidates have landed in the ledger.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The collector is a read-only Python scanner over Claude Code’s JSONL transcripts. It’s incremental, and its first run on a host only records a baseline. A candidate is either a &lt;strong&gt;snippet&lt;/strong&gt;, where the user blamed the agent or the agent admitted a mistake, or a &lt;strong&gt;digest&lt;/strong&gt;, where nobody said anything but the session ran destructive commands or something notably broke. A bash gate runs the collector and wakes the judge only if there are candidates. Before the gate, a quiet run cost about 2.1 million tokens, measured, just to load an agent that ran one command and found nothing.&lt;/p&gt;
&lt;p&gt;The judge runs on Sonnet. Its envelope allows the file tools, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;cp&lt;/code&gt;, the collector, the notifier and one publish script. The sweep reads every transcript on the machine, so it’s the last thing that should hold a general git grant. The publish script is its only way to commit or push. It takes a commit message and nothing else, and it checks that the remote really is the ledger repo, because a repointed checkout would otherwise send a private record somewhere else. On a recent run the judge tried to read the collector’s source and was denied. It noted the denial in its report and finished.&lt;/p&gt;
&lt;h2 id=&quot;why-a-model-judges-instead-of-rules&quot;&gt;Why a model judges instead of rules
&lt;a class=&quot;heading-anchor&quot; href=&quot;#why-a-model-judges-instead-of-rules&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The scanner is deliberately high-recall. It fires on &lt;code&gt;rm -rf&lt;/code&gt;, &lt;code&gt;git reset --hard&lt;/code&gt;, &lt;code&gt;pkill&lt;/code&gt;, “I broke” and “why did you”, and most hits are harmless: a job clearing its own scratch directory, a deliberate revert, a process I asked it to stop. What matters is whether it cost anything, whether it was the agent’s doing, and whether it’s already filed under another title. Regexes can’t answer that. One &lt;code&gt;--no-verify&lt;/code&gt; commit was excluded because no hooks were installed, so it bypassed nothing.&lt;/p&gt;
&lt;p&gt;Misattribution settled it: blaming a tool or upstream project for a problem the agent caused, and acting on that. It hides itself, because a bug that has been successfully blamed on someone else is a bug nobody looks for again. It also trips none of the obvious signals. There’s no confession and no complaint, just a version pin or a workaround. The scanner watches for the language of blame, and the judge is told to check what the session did to the accused tool first. The case it was built around: an agent wrote &lt;code&gt;MaxAge=9d   # comment&lt;/code&gt; into a timer and my monitoring went blind. The easy reading was that node_exporter is strict about inline comments. Actually, my own parser was treating the comment as part of the value.&lt;/p&gt;
&lt;h2 id=&quot;the-judge-can-t-weigh-evidence-it-s-never-shown&quot;&gt;The judge can’t weigh evidence it’s never shown
&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-judge-can-t-weigh-evidence-it-s-never-shown&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The first collector matched about twenty confession phrases. On 14 July one session blinded my monitoring for about twenty minutes, committed 14 embedded git repos to &lt;code&gt;main&lt;/code&gt;, and spawned a runaway agent that had to be killed. The collector missed all three, because nobody used the magic words.&lt;/p&gt;
&lt;p&gt;So the scanner started reading tool calls, and the first run still threw the 14-repos incident out for lack of evidence. The judge was right: it had never seen any. The session had also confessed to other things, so it took the snippet path and its commands were never shown. Now every session with snippets gets its commands attached too. The damaging command, &lt;code&gt;git add -A&lt;/code&gt;, exited 0 and matched no destructive pattern. The proof was in its output, &lt;code&gt;warning: adding embedded git repository&lt;/code&gt; fourteen times, so the scanner now reads the output of successful commands as well. And the evidence list was capped at eight commands, which it filled with the first eight: scratch-directory churn. It now keeps the worst:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;python&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; danger_rank&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;cmd&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    c&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; cmd&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;lower&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span&gt; re&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;search&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;git&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;rm&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;reset&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--hard&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;checkout&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;-f&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--force&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;commit&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--amend&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;clean&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;   #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; rewrites history or discards work&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span&gt; re&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;search&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;zfs&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;destroy&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;drop&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;table&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;database&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;truncate&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;shred&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/dev/&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;   #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; data destruction&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span&gt; re&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;search&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;rm&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;z&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; and&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; not&lt;/span&gt;&lt;span&gt; re&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;search&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/tmp&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/var/tmp&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;scratch&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;   #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; deleting something that is not scratch&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    ...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I filed that incident by hand.&lt;/p&gt;
&lt;h2 id=&quot;not-losing-what-it-hasn-t-judged&quot;&gt;Not losing what it hasn’t judged
&lt;a class=&quot;heading-anchor&quot; href=&quot;#not-losing-what-it-hasn-t-judged&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;On 13 July the judge died on a spend limit. The scanner had already marked those transcripts as read, so the candidates were gone. And because my transcript reaper deletes only what the sweep has read, the sources had just become deletable. Seen-state is now two-phase. Candidates are staged as pending and promoted only after a successful publish, so a failed run leaves them for the next one.&lt;/p&gt;
&lt;h2 id=&quot;three-from-the-ledger&quot;&gt;Three from the ledger
&lt;a class=&quot;heading-anchor&quot; href=&quot;#three-from-the-ledger&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;A partial update that was a full replace (HIGH).&lt;/strong&gt; An agent called the NAS’s app-management API with only the environment variables it meant to change. The API replaced the whole config, blanking the app’s database password, its Redis password and the encryption key protecting every credential the app stored. The app was down about eight minutes, and the key came back only through root access to the old container. &lt;em&gt;Lesson: treat a partial update as a full replacement until proven otherwise.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cleanup that matched too much, twice (HIGH).&lt;/strong&gt; While tidying up its own test sessions, an agent filtered the shared session list by name and time window and deleted the matches, including my real sessions and the one I was typing into. It reported the mistake, then did it again the same day. At least five sessions were destroyed beyond recovery. &lt;em&gt;Lesson: delete only what you can prove you created.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deleting the evidence of its own bug (HIGH).&lt;/strong&gt; An agent decided a price-history file was corrupt, with “905 missing trading days”, and deleted it. The corruption was its own validation bug: a parquet round-trip had renamed the date column, which broke its comparison. The free source returned nothing and the paid one was rate-limited, so the data stayed lost. &lt;em&gt;Lesson: quarantine suspect data instead of deleting it, and check a corruption diagnosis against a known-good sample before acting on it.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-it-taught-me-about-working-with-agents&quot;&gt;What it taught me about working with agents
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-taught-me-about-working-with-agents&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Confession isn’t a signal.&lt;/strong&gt; The costly incidents were the ones nobody mentioned, and exit code 0 looked fine while the output said otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repeated mistakes need mechanisms, not reminders.&lt;/strong&gt; A pattern that recurs three times gets its own line in the ledger, and the fixes that stuck were mechanical: a wrapper, a gate, a check. After a gate preview spawned a real agent, every gate got a dry-run flag.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The judge still needs a person behind it.&lt;/strong&gt; It wrongly excluded two real incidents in its first days. Its prompt tells it to be strict and honest, “including about incidents caused by Claude sessions much like this one”, and several entries are about the fleet’s own agents.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;status&quot;&gt;Status
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;As of 20 September 2026 the ledger has 47 incidents: 17 HIGH, 16 MEDIUM and 14 LOW. It was seeded on 11 July from about 113 transcripts. The sweep runs weekly on the dev VM as a job in my claude-fleet control plane. It ran nightly for a while, but because the collector is incremental, a weekly run judges the same candidates in one batch. A recent week scanned 362 new transcripts, passed 50 candidates to the judge (the per-run cap) and filed three.&lt;/p&gt;
&lt;p&gt;Limits: it reads only Claude Code transcripts, and anything past the cap waits a week. The judge sees snippets rather than whole transcripts and is told not to invent detail, so thin evidence turns into an exclusion. The ledger itself stays private because its entries describe my own systems.&lt;/p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What’s next
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-s-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The sweep is being released as a standalone tool. If I started again, I’d build the command-reading scanner and the two-phase state first, because I learned both by losing candidates. A redacted public cut of the ledger exists, but it lags the real one, and I cut it by hand on purpose, because deciding what’s safe to publish is a judgement call, not a diff.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/ai-incidents&quot;&gt;github.com/sudolulo/ai-incidents&lt;/a&gt;: the standalone sweep.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Running coding agents unattended: an instruction is not a security boundary</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/claude-fleet/"/>
        <id>https://arch.fyi/blog/posts/claude-fleet/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/claude-fleet/">&lt;p&gt;Coding agents are good at recurring judgement work: reading a repo for bugs, checking that the docs still match the machines, going back over last week’s sessions for mistakes. Running that work on a timer is harder than doing it at a keyboard, because nobody is there to approve a command, notice a loop or watch the meter. claude-fleet is the control plane I built for it. It syncs one agent config to every machine, shows every session on every host in one place, and runs scheduled headless jobs inside declared permission envelopes, with results pushed to my phone. The meter problem was real: before I measured anything, the fleet’s agent jobs were on course for about 47.6 billion tokens a year, roughly 87% of it from one hourly job.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One config everywhere.&lt;/strong&gt; Agent settings, instructions and hooks live in git and are symlinked into place on each host. An hourly sync pulls the repos, relinks the config and converges the timers. The pull is the deployment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session visibility.&lt;/strong&gt; &lt;code&gt;fleet-sessions&lt;/code&gt; lists every agent session on every host in one table, or writes an HTML dashboard.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scheduled jobs.&lt;/strong&gt; Deterministic collectors (host health, config drift) don’t need a model. Agent jobs handle the work that needs judgement. &lt;code&gt;repo-review&lt;/code&gt; reads all my repos and reports bugs and security problems without ever fixing them. &lt;code&gt;homelab-audit&lt;/code&gt; compares the docs with the live machines and opens a pull request. &lt;code&gt;ai-incidents&lt;/code&gt; reads my own agent transcripts for incidents, and has its own write-up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push notifications and a dead-man’s switch.&lt;/strong&gt; Jobs report to my phone through ntfy, with a priority set by the outcome. Every job also writes a heartbeat into Prometheus, so a job that silently stops running raises an alert.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/claude-fleet-agent-run.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/claude-fleet-agent-run.svg&quot; alt=&quot;Flowchart. A git forge holding the three fleet repos feeds an hourly fleet-sync on every host, which links the shared agent config and installs systemd timers that declare their host. A timer runs a deterministic gate. If there is no work, the gate writes a heartbeat. If there is, it picks a model and starts fleet-agent, which reads the job&amp;#39;s envelope and runs a headless agent with everything else denied. The agent can use read-only wrappers and one publish script, which writes to the job&amp;#39;s own repo. fleet-agent writes the heartbeat, which feeds a Prometheus dead-man&amp;#39;s switch, and pushes a notification to the phone.&quot; width=&quot;749&quot; height=&quot;1019&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;The model only runs in the middle of this chain. Code decides whether it runs at all, and code does its only write.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The fleet is three repos. &lt;code&gt;fleet-core&lt;/code&gt; is the harness: config, sync, session tools, notifier and heartbeat. &lt;code&gt;fleet-ops&lt;/code&gt; holds the deterministic jobs. &lt;code&gt;claude-fleet&lt;/code&gt; holds only the agent jobs.&lt;/p&gt;
&lt;p&gt;Every job is a systemd user timer with an extra &lt;code&gt;[X-Fleet] Host=&lt;/code&gt; section. It names the host that runs the job, or an alias such as &lt;code&gt;@primary&lt;/code&gt;. Each host installs only its own timers. The sync refuses to run if any timer leaves the line out, because an earlier job had quietly ended up scheduled nowhere.&lt;/p&gt;
&lt;p&gt;An agent job is a Markdown file, with the prompt in the body and the permission envelope in the front matter. &lt;code&gt;fleet-agent&lt;/code&gt; runs it through &lt;code&gt;claude -p&lt;/code&gt; headless. A headless agent can’t ask for permission, so any tool call outside the shared allowlist and the job’s own &lt;code&gt;allow:&lt;/code&gt; list is denied automatically. The agent notes the denial and carries on. Widening an envelope is a reviewed git change, not a runtime flag, and the runner rejects some envelopes outright:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;case&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; in&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;  B&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;s&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;h&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;Bash(*)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;Bash(*:*)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    echo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;fleet-agent: blanket &amp;#39;&lt;/span&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;#39; is refused — scope it, e.g. Bash(df:*)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; &amp;gt;&amp;amp;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    exit&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 5&lt;/span&gt;&lt;span&gt;;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;  &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;mcp__&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;__*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    echo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;fleet-agent: whole-server &amp;#39;&lt;/span&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;#39; is refused — name individual tools&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; &amp;gt;&amp;amp;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    exit&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 5&lt;/span&gt;&lt;span&gt;;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;esac&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It also rejects &lt;code&gt;bypassPermissions&lt;/code&gt; and loads only the MCP servers a job declares.&lt;/p&gt;
&lt;h2 id=&quot;the-envelope-has-to-be-the-only-door&quot;&gt;The envelope has to be the only door
&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-envelope-has-to-be-the-only-door&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The first version ran agents with permission checks bypassed and an unrestricted shell. I replaced it the same day with plain scripts, and agents came back the next day inside envelopes. The envelopes then leaked in three ways that no prompt could have fixed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Grants that were really blank cheques.&lt;/strong&gt; The review job’s prompt said never to modify a reviewed repo, but it held &lt;code&gt;Bash(git:*)&lt;/code&gt;: &lt;code&gt;git push --force&lt;/code&gt; to every repo the host’s SSH key reaches. The public-records job, the one agent that reads untrusted input from the web, held a network client, an unrestricted interpreter and git, which is every link of an exfiltration chain. Now the review job gets &lt;code&gt;ro-git&lt;/code&gt;, which allows read verbs only. Its one write is a publish script that pushes to its own report repo and checks the remote first. &lt;code&gt;ro-git&lt;/code&gt; has to parse past git’s global flags, because &lt;code&gt;Bash(git -C:*)&lt;/code&gt; also matches &lt;code&gt;git -C /repo push --force&lt;/code&gt;. Even so, its first version allowed &lt;code&gt;git -c protocol.ext.allow=always clone &#39;ext::sh -c whoami&#39;&lt;/code&gt;, which runs a shell command through a “read” verb. It now refuses &lt;code&gt;-c&lt;/code&gt; and &lt;code&gt;ext::&lt;/code&gt; outright.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A config layer nobody managed.&lt;/strong&gt; The jobs run from the home directory, so an unmanaged local settings file there loaded as project settings on every run. It granted &lt;code&gt;Bash(git *)&lt;/code&gt;. The review job’s log showed raw &lt;code&gt;git&lt;/code&gt; running ten times, and the wrappers built to be its only git ran zero times. The whole write-isolation design had been inert. The fix was one flag, &lt;code&gt;--setting-sources user,project&lt;/code&gt;, confirmed with a live probe.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A permission for a tool that didn’t exist.&lt;/strong&gt; For twelve nights the docs audit pushed a branch and was then denied the pull request, because its envelope named the PR tool wrongly. The right name would have failed too, since the MCP server deliberately holds a read-only token. Now the publish script opens the PR with a write token the model never sees.&lt;/p&gt;
&lt;p&gt;The lesson from all three: an instruction is not a security boundary. A capability the agent doesn’t hold is.&lt;/p&gt;
&lt;h2 id=&quot;don-t-pay-a-model-to-find-out-there-s-nothing-to-do&quot;&gt;Don’t pay a model to find out there’s nothing to do
&lt;a class=&quot;heading-anchor&quot; href=&quot;#don-t-pay-a-model-to-find-out-there-s-nothing-to-do&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;An agent is billed for context times turns, and context accumulates, so anything read on turn 3 is paid for again on every later turn. The hourly podcast-processing job measured about 5 million tokens a run, some runs took up to 234 turns, and 11 of 27 runs in a week did nothing at all. Because the account is a subscription, that cost came out of quota headroom rather than an invoice. The session that measured it priced it at API rates anyway and reported a $48,000-a-year bill that didn’t exist, and that mistake went into the incident ledger too.&lt;/p&gt;
&lt;p&gt;The fix is a gate: the timer runs a bash script, not the agent, and the script answers the deterministic question first. In the docs audit, if the checker finds the docs clean, no model is loaded. Otherwise mechanical drift goes to Haiku, a false sentence to Sonnet, and anything that would mislead me during an incident to Opus. The incident sweep’s gate saves about 2.1 million tokens per quiet run, the measured cost of loading an agent to run one command and find nothing. The repo-review gate enforces a monthly budget capped at about $17 at API-equivalent rates, in bash, where a prompt can’t argue with it.&lt;/p&gt;
&lt;p&gt;Loading fewer tools also helped, because tool definitions are re-read every turn. One Sonnet turn with all 30 built-in tool definitions cost 29,348 tokens. With only &lt;code&gt;Bash&lt;/code&gt; and &lt;code&gt;Write&lt;/code&gt; it cost 15,778.&lt;/p&gt;
&lt;h2 id=&quot;a-dead-man-s-switch-that-believed-the-job&quot;&gt;A dead-man’s switch that believed the job
&lt;a class=&quot;heading-anchor&quot; href=&quot;#a-dead-man-s-switch-that-believed-the-job&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;On 13 July the incident sweep hit a spend limit. It threw away its candidates, sent the error text to my phone as its result, and recorded exit code 0. In 39 samples over 19 hours, the exit code never left 0, and both alerts stayed green through a job that was dead for a day. &lt;code&gt;claude -p&lt;/code&gt; exits 0 while printing the limit message, and the heartbeat was written before anything read the output.&lt;/p&gt;
&lt;p&gt;The runner now judges the output, not the exit status. A usage limit gets its own exit code. The “last run failed” alert ignores it, but it doesn’t count as a success either, so a limit that never lifts still ages into “overdue”. The runner also reads the reset time from the provider’s message and schedules a one-shot systemd retry for five minutes after it. Before this, two weekly jobs had each stayed red for a week over limits that lifted within hours. Gates follow the same rule from the other side: a skipped run writes its own success heartbeat, or the switch fires because the job is healthy.&lt;/p&gt;
&lt;h2 id=&quot;status&quot;&gt;Status
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;claude-fleet is private. The three repos have about 650 commits between them since 8 July 2026, and fleet-core is at 0.7.0. It’s Bash and Python on systemd user timers, with Prometheus and Grafana for alerting and a self-hosted ntfy.&lt;/p&gt;
&lt;p&gt;The honest limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It’s less of a fleet than it was. It started with agents on three machines, and for a while the scheduler had a node of its own. Now the agents run on the dev VM. Folding the scheduler into it was a trade: rebuild the dev VM and you rebuild the scheduler with it.&lt;/li&gt;
&lt;li&gt;Most agent jobs are pinned to the dev VM because that’s where the checkouts are. On a host with fewer clones, repo review would check a fraction of my repos and still report green.&lt;/li&gt;
&lt;li&gt;One job keeps arbitrary code execution by design, scoped to its own project, because there the model’s output is the product. The risk is written down, not papered over.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I’d do differently
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-i-d-do-differently&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;I’d start with the gate and the wrapper, not the prompt. Nearly every fix here moved a decision out of the model and into code: whether there is work, which model to use, and what can be written where. I’d also test every envelope on day one with a call that should be denied.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The repo is private. The parts that generalize are published separately:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-broker&quot;&gt;opencode-broker&lt;/a&gt;: model routing for OpenCode, with quota circuits and budget balancing. It combines what used to be opencode-router, opencode-gateway and opencode-hud. The router and the HUD started in this fleet’s harness.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-guard&quot;&gt;opencode-guard&lt;/a&gt;: permission modes, credential guards and secret redaction for OpenCode. It was formerly opencode-guardrails.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/ai-incidents&quot;&gt;ai-incidents&lt;/a&gt;: the transcript sweep, as a standalone tool.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Every agent session leases its model from one broker</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/opencode-broker/"/>
        <id>https://arch.fyi/blog/posts/opencode-broker/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/opencode-broker/">&lt;p&gt;I run many opencode sessions at once, against three cloud subscriptions and two local GPUs. Out of the box, each session picks one model and keeps it. The architecture decision and the variable rename draw on one budget, a session that hits a plan limit stops, and no session knows what the others spend. opencode-broker is a small daemon that fixes this. Every session, subagent and OpenAI-style client I point at it asks for a model lease, turn by turn, and it answers from one shared ledger. The router at its core reached v0.51 in three weeks and 137 commits. It and its two companions, opencode-gateway and opencode-hud, become one project for release.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;An agent names a &lt;em&gt;tier&lt;/em&gt;, not a model. The tiers are ranked by cost: &lt;code&gt;worker&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;fast-build&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;smart&lt;/code&gt; and &lt;code&gt;deep&lt;/code&gt;, plus a separate &lt;code&gt;classifier&lt;/code&gt; lane. The test runner sits on &lt;code&gt;worker&lt;/code&gt;, reviewers on &lt;code&gt;review&lt;/code&gt; and implementers on &lt;code&gt;build&lt;/code&gt;. The researcher sits on &lt;code&gt;smart&lt;/code&gt;, because its output is the facts that design gets built on. &lt;code&gt;general&lt;/code&gt; inherits its parent’s tier, so delegated design work never lands on a worker model by accident. A deterministic content match on medical, legal or electrical questions raises the floor to &lt;code&gt;smart&lt;/code&gt; or &lt;code&gt;deep&lt;/code&gt;, whatever the agent.&lt;/p&gt;
&lt;p&gt;A session also has a &lt;em&gt;profile&lt;/em&gt;. &lt;code&gt;auto&lt;/code&gt; routes by tier and &lt;code&gt;manual&lt;/code&gt; locks to your own pick. The rest, such as &lt;code&gt;local&lt;/code&gt;, &lt;code&gt;private&lt;/code&gt; and &lt;code&gt;vision&lt;/code&gt;, are lanes of specific local models that stay on the LAN unless the config says otherwise. F11 in the HUD, a plugin for opencode’s terminal UI, picks one per session. The HUD also shows a &lt;code&gt;FALLBACK:&amp;lt;target&amp;gt;&lt;/code&gt; badge when the broker has degraded you, and how much of each provider’s window is used.&lt;/p&gt;
&lt;p&gt;Services that only speak &lt;code&gt;OPENAI_BASE_URL + key + model&lt;/code&gt;, such as Open WebUI and Home Assistant’s voice assistant, point at the gateway. Each request gets a fresh lease, the same circuits and the same accounting.&lt;/p&gt;
&lt;p&gt;There is no built-in model. With no config, every lease fails with a clear error, because a router that ships a provider’s name in its code will quietly spend a metered account.&lt;/p&gt;
&lt;p&gt;Each piece exists somewhere else: LiteLLM, claude-code-router, OmniRoute and oh-my-opencode each cover part of this. What I didn’t find was a single broker that every concurrent session leases from, per turn, against one ledger.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/opencode-broker-architecture.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/opencode-broker-architecture.svg&quot; alt=&quot;Architecture of opencode-broker. opencode sessions and subagents take a lease from a broker daemon on a Unix socket once per turn. The HUD in the terminal UI reads status from the broker. Open WebUI and Home Assistant call an OpenAI-compatible gateway, which takes a lease from the broker for each request. The broker keeps leases, circuits and a budget ledger, reads provider usage and swaps local models. Model traffic goes directly from the sessions and from the gateway to the cloud subscriptions and to llama.cpp on the local GPUs, shown as dotted lines.&quot; width=&quot;702&quot; height=&quot;477&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;The broker decides and never carries traffic. Sessions and the gateway talk to providers directly, with the model they were leased.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The broker is a Node daemon that speaks JSON over a Unix socket and never proxies an opencode session. The router plugin hooks &lt;code&gt;chat.message&lt;/code&gt;, the last hook that can still change the model before the provider call. It leases with the session’s profile, tier and context size, binds the turn to the leased model, reports usage and failures, and releases the lease when the session goes idle. The broker handles one request at a time, so two sessions can’t both take the last local slot, and it logs every decision with its reasons.&lt;/p&gt;
&lt;p&gt;Selection drops anything with an open circuit, a quarantined provider, too small a context window, no free slot or an unloaded model. It then spends where the most budget headroom is left, breaking ties by model fit, provider weight, discounts, load and round-robin.&lt;/p&gt;
&lt;p&gt;Privacy lanes fail loudly instead of degrading. Offline profiles run shell commands in a sandbox with no network. A profile’s fallback rungs are filtered to local targets unless the profile is named in &lt;code&gt;profileCloudEgress&lt;/code&gt;, a separate top-level list, so pasting a tier’s cloud-heavy rungs into a profile can’t grant egress by accident.&lt;/p&gt;
&lt;h2 id=&quot;hard-problems&quot;&gt;Hard problems
&lt;a class=&quot;heading-anchor&quot; href=&quot;#hard-problems&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;h3 id=&quot;an-unreadable-quota-looks-like-an-empty-one&quot;&gt;An unreadable quota looks like an empty one
&lt;a class=&quot;heading-anchor&quot; href=&quot;#an-unreadable-quota-looks-like-an-empty-one&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Where a provider reports its own usage, the broker reads those figures. Otherwise it falls back to its own ledger, which only sees this machine’s requests and so can only undercount. On 2026-09-04 Alibaba’s usage CLI had lost its token. That subscription read 2.75% used against Anthropic’s 32% and OpenAI’s 18%, and every &lt;code&gt;build&lt;/code&gt; lease went to it. The lane I could account for least was the one being drained fastest.&lt;/p&gt;
&lt;p&gt;The fix floats an unobserved provider up to the mean of the observed ones:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;javascript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; observed&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; new&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; Map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; unobserved&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; target&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; of&lt;/span&gt;&lt;span&gt; targets&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;  if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;kind&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !==&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;cloud&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; continue&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;           //&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; local has no quota&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;  if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;isObserved&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;providerID&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;!&lt;/span&gt;&lt;span&gt;observed&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;has&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;providerID&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;      observed&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;set&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;providerID&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; utilizations&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ??&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  }&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; else&lt;/span&gt;&lt;span&gt; unobserved&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;push&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;target&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;!&lt;/span&gt;&lt;span&gt;observed&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ||&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !&lt;/span&gt;&lt;span&gt;unobserved&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;length&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; null&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; balance&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;...&lt;/span&gt;&lt;span&gt;observed&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;values&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;reduce&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;a&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; b&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; a&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; +&lt;/span&gt;&lt;span&gt; b&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; /&lt;/span&gt;&lt;span&gt; observed&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;const&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; t&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; of&lt;/span&gt;&lt;span&gt; unobserved&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;  utilizations&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;set&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;max&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;utilizations&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ??&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; balance&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mean is per provider, not per target, because Anthropic fielding two models had dragged it to 0.2733 when the real mean of 0.32 and 0.18 is 0.25. Readings also survive a restart now: with an empty cache nothing was observed, the correction stood down, and Alibaba took six build leases in a row.&lt;/p&gt;
&lt;p&gt;Budgets calibrate themselves too. Providers don’t publish plan limits, so a rate-limit complaint sets the shortest window’s ceiling to what the ledger had spent at that moment, and spending past a learned ceiling without complaint raises it.&lt;/p&gt;
&lt;h3 id=&quot;whose-fault-is-a-failure&quot;&gt;Whose fault is a failure?
&lt;a class=&quot;heading-anchor&quot; href=&quot;#whose-fault-is-a-failure&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;A quota error covers the whole subscription, so it opens a provider-wide circuit that holds until the exact reset. The reset comes from the error, else the provider’s usage API, else a 15-minute re-probe. A lease also waits for a due usage refresh, so the first request after a five-hour window fills can’t still get Opus.&lt;/p&gt;
&lt;p&gt;A failure the broker doesn’t recognize fences one model for five minutes and counts against its provider. Two different failing models within 15 minutes quarantine the provider, and 30 minutes later probation admits exactly one lease. That sounds cautious, but deterministic failures replay across sibling models and manufacture exactly that evidence:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An expired OAuth token quarantined a healthy OpenAI lane for a day.&lt;/li&gt;
&lt;li&gt;A transcript whose tool calls had lost their results failed over to Anthropic. Opus 5 rejected it, Opus 4.8 rejected the same bytes seven seconds later, and the provider serving every &lt;code&gt;build&lt;/code&gt; and &lt;code&gt;smart&lt;/code&gt; lane was out with no sick models at all.&lt;/li&gt;
&lt;li&gt;The router’s own “routed model mismatch” guard took three providers dark in one day.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So failure classification grew to ten kinds, each with its own scope:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;plain&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;quota     provider, until the reported reset&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;connect   provider, 60 s&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;rate      one model, 5 min&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;overload  one model, 15 s&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;auth      one model, 1 min&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;model     one model, no expiry&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;context, payload, noop   release the lease only&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;other     one model, 5 min, plus evidence against the provider&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Only the last line counts against a provider, and every new failure shape reached it first.&lt;/p&gt;
&lt;h3 id=&quot;local-gpus-are-slots-and-models-move&quot;&gt;Local GPUs are slots, and models move
&lt;a class=&quot;heading-anchor&quot; href=&quot;#local-gpus-are-slots-and-models-move&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;A local target declares how many requests it serves at once and a per-slot context window. llama.cpp’s router mode lists every configured model whether it’s loaded or not, so a model is eligible only when its status reads &lt;code&gt;loaded&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Several models share two cards, and some can’t be loaded together. When a lease fails because a model isn’t resident, the broker runs that target’s &lt;code&gt;prepareCommand&lt;/code&gt;, as an argument list with no shell, and doesn’t wait for it. A 27B swap took 2 minutes 40 seconds, and the lease call sits inside a turn. The refusal carries a machine-readable &lt;code&gt;target-preparing&lt;/code&gt; code. Before waits were split from refusals, 650 of 1,500 logged decisions read “refused”, and 617 of those were a model loading: a 43% failure rate on a router that was working.&lt;/p&gt;
&lt;p&gt;Cost decides which swaps happen. A 70B model spans both cards and evicts three others, including the one Home Assistant’s voice assistant uses. It has its own profile rather than being a second target in another lane, because the broker picks among a lane’s local targets by round-robin, and a cost like that should be chosen on purpose, never won on a tie-break.&lt;/p&gt;
&lt;h2 id=&quot;status&quot;&gt;Status
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The router is at v0.51.0, the gateway at 0.5.0 and the HUD at 0.14.0: 202 commits between 31 August and 21 September 2026, with no tagged releases yet. It’s about 10,000 lines of JavaScript plus about as many lines of tests, and the broker and gateway have no npm dependencies. It runs on my dev machine, in front of three subscriptions and llama.cpp on two V100 GPUs.&lt;/p&gt;
&lt;p&gt;It’s built against opencode 1.18.22 and relies on observed wire shapes, not documented API. Limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No plugin hook fires on a provider error, so recovery happens on the next turn, through a synthetic “continue” prompt capped at two per ten minutes.&lt;/li&gt;
&lt;li&gt;Stock opencode has no clean way to rebind a turn’s model, and the workaround does nothing on a subagent’s first turn. A small engine patch fixes it, and I’d rather that patch went away than grew.&lt;/li&gt;
&lt;li&gt;The gateway can’t fail over after the first streamed byte.&lt;/li&gt;
&lt;li&gt;It serves one host over a Unix socket.&lt;/li&gt;
&lt;li&gt;The risk floor is a keyword match, so coding vocabulary like “circuit breaker” can trip it. Narrowing it is in progress.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What’s next
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-s-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;First, merging the three repositories. After that, I may fold in the child-session cleanup I’ve written about before as a session janitor: abort, prove the loop can’t write, delete, then read the row back.&lt;/p&gt;
&lt;p&gt;If I rebuilt the failure handling, I’d invert its default: an unrecognized failure would fence only the one model, and a provider would be indicted only on positive evidence. I’d keep deterministic escalation. A content match escalates every time, while a model asked whether to escalate answers differently on different days.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Repository: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-broker&quot;&gt;github.com/sudolulo/opencode-broker&lt;/a&gt;. The socket protocol and the on-disk state contract are documented in its &lt;code&gt;docs/&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;Companion project: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-guard&quot;&gt;opencode-guard&lt;/a&gt;, whose command classifier leases through the broker’s &lt;code&gt;classifier&lt;/code&gt; lane.&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A command classifier should only run when nobody can be asked</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/opencode-guard/"/>
        <id>https://arch.fyi/blog/posts/opencode-guard/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/opencode-guard/">&lt;p&gt;A coding agent with a shell runs whatever it decides to run. opencode controls that with permission globs, and they have a hole. Rules resolve last match wins, and an agent’s own &lt;code&gt;allow&lt;/code&gt; block is appended after the global rules, so one &lt;code&gt;cat *&lt;/code&gt; grant in any agent definition overrides a global deny on &lt;code&gt;~/.ssh&lt;/code&gt;. opencode-guard (previously opencode-guardrails) is a plugin that adds Claude-Code-style permission modes and a safety floor that no permission rule can be appended past. Its central rule is that the classifier model answers exactly one question: &lt;em&gt;may this run with nobody to ask?&lt;/em&gt; Where a person will be asked, the model decides nothing. Before any model is involved, a static table of more than 120 read-only commands settles ordinary reads for free.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Each session has a mode, flipped with F9 in the opencode-broker HUD or &lt;code&gt;oc-mode&lt;/code&gt; from a shell:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;manual&lt;/code&gt; asks about everything opencode would ask about.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;edits&lt;/code&gt; lets file edits, reads and searches through, and asks about everything else.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;auto&lt;/code&gt; lets everything through except a shell command the classifier won’t call SAFE. That command is refused, with a reason the agent can act on.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;god&lt;/code&gt; runs everything. It’s per session and only honoured when a person is present, so an unattended run keeps the floor whatever its mode file says.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A second knob, the level, is the escape hatch: &lt;code&gt;on&lt;/code&gt;, &lt;code&gt;static&lt;/code&gt; (never call a model) or &lt;code&gt;off&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The floor applies to every tool that starts a process, including a background shell that nobody waits on. It is &lt;a rel=&quot;external&quot; href=&quot;https://github.com/kenryu42/cc-safety-net&quot;&gt;cc-safety-net&lt;/a&gt;’s static analysis of destructive commands plus a credential guard, and it throws in every mode but god. The credential guard also covers opencode’s native &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;glob&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;, which never pass through a shell.&lt;/p&gt;
&lt;p&gt;Tool output is redacted in every mode, god included, because a secret in a transcript was never a permission question. The exceptions are the tools that feed an edit, which would write the marker back to disk. Redaction is driven by key names like &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt; and a trailing &lt;code&gt;_key&lt;/code&gt;, plus PEM blocks, not by guessing what looks random, which would mangle the hashes and IDs an agent needs. It appends a count so the agent doesn’t mistake a marker for a failure. &lt;code&gt;oc-reveal 60&lt;/code&gt; opens a one-minute unredacted window from a real terminal, and the plugin refuses any shell command that names it.&lt;/p&gt;
&lt;p&gt;The prior art is plain: cc-safety-net is a dependency doing the destructive-command analysis, and the four modes follow the shape of Claude Code’s permission modes.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/opencode-guard-decision.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/opencode-guard-decision.svg&quot; alt=&quot;Decision flow for a tool call in opencode-guard. A tool call first meets the hard checks: a stale turn, a bad dispatch to a read-only agent, a cc-safety-net deny, or a credential path. If any fails, the call is refused with a reason the agent can act on. Otherwise the question is whether anyone will be asked. In manual and edits modes, opencode asks the person, and an approved command runs. In auto mode or an unattended session, a command the static table recognizes as read-only runs. Anything else goes to the classifier: a SAFE verdict runs, while RISKY or no verdict is refused. Everything that runs has secrets redacted from its output.&quot; width=&quot;475&quot; height=&quot;1116&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;The model sits on one branch. Attended god mode skips the floor and is left out of this picture.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;On opencode 1.18.22 the &lt;code&gt;permission.ask&lt;/code&gt; hook never fires, so a plugin can’t turn a verdict into a prompt. It can throw, stand aside, or answer a permission that has already been raised. The design follows from that. Hard checks throw in &lt;code&gt;tool.execute.before&lt;/code&gt;. In &lt;code&gt;manual&lt;/code&gt; and &lt;code&gt;edits&lt;/code&gt;, the plugin stands aside and opencode’s own prompt reaches the person. In &lt;code&gt;auto&lt;/code&gt;, it answers the raised permission when the verdict is SAFE.&lt;/p&gt;
&lt;p&gt;“Unattended” means a headless command such as &lt;code&gt;opencode run&lt;/code&gt; or &lt;code&gt;serve&lt;/code&gt;, or a session started with &lt;code&gt;--auto&lt;/code&gt;. The plugin reads it from &lt;code&gt;/proc/self/cmdline&lt;/code&gt;, because the terminal UI’s worker strips the flags from &lt;code&gt;process.argv&lt;/code&gt;, and any doubt counts as unattended.&lt;/p&gt;
&lt;h2 id=&quot;hard-problems&quot;&gt;Hard problems
&lt;a class=&quot;heading-anchor&quot; href=&quot;#hard-problems&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;h3 id=&quot;settling-reads-without-a-model&quot;&gt;Settling reads without a model
&lt;a class=&quot;heading-anchor&quot; href=&quot;#settling-reads-without-a-model&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;The first version split command lines on &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt; and &lt;code&gt;;&lt;/code&gt; without regard to quotes. &lt;code&gt;ssh nas &quot;test -w X &amp;amp;&amp;amp; echo yes || echo no&quot;&lt;/code&gt; became four fragments, one with an unbalanced quote, so nothing matched and every such command paid for a model call. The classifier shares a GPU slot with the session’s own model, so every avoidable call is latency now and an HTTP 500 under contention later.&lt;/p&gt;
&lt;p&gt;The lexer now honours quotes, and a segment with command substitution, or a redirect anywhere but &lt;code&gt;/dev/null&lt;/code&gt;, is never a read. A line is a read only if every segment is: &lt;code&gt;cd repo &amp;amp;&amp;amp; rg foo&lt;/code&gt; passes, while &lt;code&gt;rg -l x | xargs rm -f&lt;/code&gt; doesn’t. 124 verbs read by default, 17 more such as &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;kubectl&lt;/code&gt; read only for listed subcommands, and 15 carry per-flag guards:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;javascript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;find&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;t&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;some&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; /&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;execdir&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;okdir&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;fls&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;fprint&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;fprintf&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;$&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;test&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;sed&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;  (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;t&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;some&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ===&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--in-place&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ||&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; /&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;a-z&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;A-Z&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;test&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;awk&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;  (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;t&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;some&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;includes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ||&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;includes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;system(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ||&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;includes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;curl&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;t&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; !&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;some&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; /&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;A-Z&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;a-z&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;cdDFTOoXKJ&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;test&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ||&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;  /&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;^&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;--&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;form&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;upload-file&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;output&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;remote-name&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;remote-header-name&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;request&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;create-dirs&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;dump-header&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;cookie-jar&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;/&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;test&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;curl&lt;/code&gt; guard matches prefixes because an earlier exact-match version let &lt;code&gt;curl -XPOST&lt;/code&gt; through as a read. &lt;code&gt;ssh host &quot;&amp;lt;command&amp;gt;&quot;&lt;/code&gt; is judged by pulling out the remote command and running it through the same rules, two hops deep at most, because being remote says nothing about what a command does.&lt;/p&gt;
&lt;h3 id=&quot;a-classifier-you-can-fail-closed-on&quot;&gt;A classifier you can fail closed on
&lt;a class=&quot;heading-anchor&quot; href=&quot;#a-classifier-you-can-fail-closed-on&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Failing closed is only safe if the classifier is available, fast and consistent, and each of those had to be measured.&lt;/p&gt;
&lt;p&gt;The lane now runs a small local model first, with cloud subscriptions as the fallback. Over 14 labelled commands run three times, a 4B model with no reasoning mode got 13 right, gave identical answers on every run, and typically took about 200 ms. A 9B model that reasons first got 12 right in 10 to 54 seconds, against a 12-second budget. Direct requests use temperature 0 and a fixed seed, because the log once held the same &lt;code&gt;docker ps --filter&lt;/code&gt; line judged SAFE once and RISKY once. Verdicts are cached by working directory and command, and the cache is shared across sessions.&lt;/p&gt;
&lt;p&gt;The prompt had to define dangerous rather than unfamiliar. An early version counted “anything that reaches beyond the project” and “anything you are unsure about” as risky, and a small model denied 63 of 96 commands, including nearly every read-only &lt;code&gt;ssh&lt;/code&gt; to the NAS. RISKY is now a closed list, and a command that only reads is SAFE wherever it runs.&lt;/p&gt;
&lt;p&gt;The worst bug was in error reporting. Every transport, auth, provider and timeout failure surfaced as “classifier returned no text”. The broker deliberately doesn’t count that against a provider, so broken lanes were never benched. The number quoted onward was 155 empty answers in 161 leases for one cloud model. When I finally measured that model, it was 6 for 6 correct. Its real problem was latency: a median of 9.6 seconds and a worst case of 24, against a 12-second deadline. A failure that can’t name itself gets explained by whoever reads it next.&lt;/p&gt;
&lt;p&gt;Privacy sits under all of this. A command line is where secrets travel inline, so under any routing profile other than &lt;code&gt;auto&lt;/code&gt; or &lt;code&gt;manual&lt;/code&gt;, a command the static table can’t settle is refused without asking any model. An unrecognized profile name counts as restrictive.&lt;/p&gt;
&lt;h3 id=&quot;subagents-inherit-and-a-cancelled-turn-is-over&quot;&gt;Subagents inherit, and a cancelled turn is over
&lt;a class=&quot;heading-anchor&quot; href=&quot;#subagents-inherit-and-a-cancelled-turn-is-over&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;A delegated child session has no mode file of its own, so it fell through to the global default, which was &lt;code&gt;manual&lt;/code&gt;. Every permission a subagent raised landed on me as a prompt, and its commands were never classified. The most restrictive default produced pure noise, and noise is how people end up clicking yes without reading. A child now inherits its parent’s mode, capped at &lt;code&gt;auto&lt;/code&gt;: nobody pressed the god key on the child. A permission it’s refused is rejected outright and appended to the result its parent gets back from the task tool, so the parent decides.&lt;/p&gt;
&lt;p&gt;Two gates sit above god mode, because they aren’t limits on what a session may run. The dispatch gate refuses a task sent to an agent that declares &lt;code&gt;capability: read&lt;/code&gt; unless the brief opens with &lt;code&gt;INTENT: read&lt;/code&gt;. It was prompted by four misdispatches in a single god-mode session. The stale-turn gate exists because opencode 1.18.22 checks the abort signal only after a tool has run. On 2026-09-21 a plugin’s startup sweep starved the event loop, Esc took a minute to land, and two subagents’ queued tool calls ran about 200 ms after the cancel. Now a tool call for a session that is no longer busy is refused, and a failed status read allows the call, so the gate can’t stall a live session.&lt;/p&gt;
&lt;h2 id=&quot;status&quot;&gt;Status
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;opencode-guard is at v0.6.0: 28 commits between 31 August and 21 September 2026, about 2,400 lines of JavaScript and 770 lines of tests on Node’s built-in runner. It depends on cc-safety-net 2.1.1 and the opencode SDK, and it’s built against opencode 1.18.22. Limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It’s a speed bump for a helpful model, not a sandbox against a hostile one.&lt;/li&gt;
&lt;li&gt;A SAFE verdict runs the command, and the static floor in front of the classifier only catches the destructive cases it knows about.&lt;/li&gt;
&lt;li&gt;Commands your own opencode config already allows are treated as settled, so the guard inherits those rules.&lt;/li&gt;
&lt;li&gt;The routed classifier creates a child session per call, and cleanup has to wait on an idle barrier that opencode 1.18 only stubs. That leaked 210 sessions at about 52 an hour, so a direct endpoint is now primary and the routed path is a fallback for fast failures only.&lt;/li&gt;
&lt;li&gt;Mode inheritance is one level deep, so a grandchild falls back to the global default.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What’s next
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-s-next&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The loop guard from opencode-agent-workflows is being folded in. It watches for a read-only call repeated with the same input and output and nothing changed in between, warns at 12 in a row, and hands a subagent back to its parent at 25. The bigger lesson I’d apply from the start: make every failure name its cause at the point it happens. Several of the worst bugs here were a refusal carrying the wrong reason.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Repository: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-guard&quot;&gt;github.com/sudolulo/opencode-guard&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Depends on: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/kenryu42/cc-safety-net&quot;&gt;cc-safety-net&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Its routed classifier leases through: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/opencode-broker&quot;&gt;opencode-broker&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Tendril: turning one multi-GPU machine into several gaming PCs</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/tendril/"/>
        <id>https://arch.fyi/blog/posts/tendril/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/tendril/">&lt;p&gt;A PC with several GPUs is still one PC: one Windows install, one keyboard, one player. The reliable
way to split it on consumer hardware is GPU passthrough, which hands each card to its own virtual
machine. Doing that by hand means reading IOMMU tables, rebinding drivers and writing libvirt XML.
Tendril does all of it for you. It’s an installable Fedora bootc appliance, about 16,000 lines of
Rust in four crates, that turns one multi-GPU machine into several gaming stations. Each station is
a Windows or SteamOS VM with a real GPU, managed from a web UI.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;You flash the installer ISO, boot the machine and open the web UI from another device. The only
thing Tendril can’t do for you is turn on VT-d or AMD-Vi in the firmware.&lt;/p&gt;
&lt;p&gt;To create a station you pick an OS, a GPU and an account name. Tendril downloads and verifies the
install media, builds the disk, an answer file and the VM, and installs the guest while you watch in
an in-browser console. When it finishes, the monitor plugged into that GPU is a gaming PC.&lt;/p&gt;
&lt;p&gt;After that there are snapshots, golden images that clone instantly, USB “seats” for each player’s
peripherals, Sunshine streaming, a kiosk mode and a low-latency mode that pins vCPUs to host cores.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The workspace is four crates, and dependencies only point one way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;capability-engine&lt;/code&gt; reads sysfs, the kernel’s view of the hardware, and decides what each GPU
can do.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;provisioning&lt;/code&gt; plans which PCI addresses to hand to &lt;code&gt;vfio-pci&lt;/code&gt;, the kernel’s passthrough driver.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;orchestrator&lt;/code&gt; renders libvirt domain XML, writes answer files and drives VMs through &lt;code&gt;virsh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;web&lt;/code&gt; is the Axum and HTMX control plane. It embeds htmx and noVNC in the binary so the
appliance works offline.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first three crates have no third-party dependencies. They shell out to &lt;code&gt;virsh&lt;/code&gt;, &lt;code&gt;qemu-img&lt;/code&gt; and
&lt;code&gt;genisoimage&lt;/code&gt; instead of linking libvirt. The sysfs readers can point at fixture directories, so
the IOMMU logic is tested without a live machine. The web UI, the on-screen console and the CLIs
all call one &lt;code&gt;provision()&lt;/code&gt; function, so a station comes out the same from any front end.&lt;/p&gt;
&lt;figure class=&quot;figure figure-wide&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/tendril-architecture.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/tendril-architecture.svg&quot; alt=&quot;Flowchart of how Tendril creates a station. Inside the Fedora bootc host image, which greenboot can roll back, sysfs data about PCI devices and IOMMU groups goes to the capability engine. The capability engine feeds the provisioning step, which plans the GPU&amp;#39;s whole IOMMU group. That plan, a checksum-verified install ISO, a seed ISO holding autounattend.xml or a kickstart, and your input from the web UI, console or CLI all go into a StationRequest. The orchestrator&amp;#39;s provision() function turns it into libvirt domain XML with Secure Boot, a TPM and host devices. libvirt with QEMU/KVM then moves the GPU group to vfio-pci and starts the station VM, which runs Windows or SteamOS on a real GPU.&quot; width=&quot;969&quot; height=&quot;898&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;Every front end builds the same StationRequest and goes through one provision() call. The GPU always moves as a whole IOMMU group.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;hard-problem-1-which-gpu-can-you-give-away&quot;&gt;Hard problem 1: which GPU can you give away?
&lt;a class=&quot;heading-anchor&quot; href=&quot;#hard-problem-1-which-gpu-can-you-give-away&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;You can’t pass through “a GPU”. What the hardware can isolate is an IOMMU group: the set of PCI
devices that the IOMMU, the hardware that fences off each device’s memory access, can’t separate.
A modern graphics card is several devices. The RTX 2070 SUPER I tested against shows up as four
functions, &lt;code&gt;0000:83:00.0&lt;/code&gt; through &lt;code&gt;.3&lt;/code&gt;: the GPU plus its audio and USB companions, all in one group.
They move together or not at all, so the plan always covers the whole group.&lt;/p&gt;
&lt;p&gt;Detection asks whether everything in a card’s group sits in the same physical slot:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;rust&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;pub&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; fn&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; viability_for&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;address&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; &amp;amp;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;str&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; groups&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; &amp;amp;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;IommuGroup&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; -&amp;gt;&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; PassthroughViability&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    let&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; Some&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;group&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; group_of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;address&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; groups&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;        return&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; PassthroughViability&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;NoIommu&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    let&lt;/span&gt;&lt;span&gt; slot&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; slot_of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;address&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    let&lt;/span&gt;&lt;span&gt; isolated&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; group&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;.&lt;/span&gt;&lt;span&gt;device_addresses&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;iter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt;all&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; slot_of&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; ==&lt;/span&gt;&lt;span&gt; slot&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span&gt; isolated&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;        PassthroughViability&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;Isolated&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;        PassthroughViability&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;SharedGroup&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An isolated group means clean passthrough. A shared group needs an ACS override, a kernel patch
that splits the group anyway at a security cost, so Tendril labels the card and leaves that to you.
No group means the IOMMU is off in the firmware, and a boot-time check puts that on the Hardware
page.&lt;/p&gt;
&lt;p&gt;A hardening pass added a rule: the GPU the firmware booted on (&lt;code&gt;boot_vga=1&lt;/code&gt; in sysfs) always stays
with the host. Before that, a single-GPU box would offer its only display for passthrough.&lt;/p&gt;
&lt;p&gt;Binding takes three sysfs writes per device: unbind the current driver, set &lt;code&gt;driver_override&lt;/code&gt; to
&lt;code&gt;vfio-pci&lt;/code&gt;, then reprobe. &lt;code&gt;tendril-apply&lt;/code&gt; defaults to a dry run that prints the writes. A
half-detached GPU can’t be cleanly put back, so a failure partway through reports how many writes
already landed and says to re-run or reboot.&lt;/p&gt;
&lt;h2 id=&quot;hard-problem-2-installing-windows-with-nobody-at-the-keyboard&quot;&gt;Hard problem 2: installing Windows with nobody at the keyboard
&lt;a class=&quot;heading-anchor&quot; href=&quot;#hard-problem-2-installing-windows-with-nobody-at-the-keyboard&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Both guest OSes resist being installed with nobody watching.&lt;/p&gt;
&lt;p&gt;A stock Windows 11 ISO can’t see a virtio disk, the paravirtual disk VMs use, so Setup stops at
“we couldn’t find any drives”. Tendril generates an &lt;code&gt;autounattend.xml&lt;/code&gt; that loads the virtio drivers
during WinPE, the first stage of setup. The virtio ISO’s drive letter isn’t predictable there, so
the file lists every driver under D:, E: and F:, and Setup skips paths that don’t exist. The same
file skips the Microsoft-account screens and creates a local account. It rides in on a small seed
ISO, attached as a third CD drive.&lt;/p&gt;
&lt;p&gt;Then there’s “Press any key to boot from CD or DVD”, which skips the installer if nobody presses a
key within about five seconds. So Tendril sends Enter through &lt;code&gt;virsh send-key&lt;/code&gt; once a second for 45
seconds, because firmware startup can be slow on a loaded host. Extra keypresses land harmlessly in
WinPE.&lt;/p&gt;
&lt;p&gt;SteamOS has no installer for generic PCs. The Steam Deck recovery image is AMD-only, so it can’t
drive an NVIDIA station. Tendril’s “SteamOS” station is Bazzite instead. Its Anaconda installer
automatically loads a kickstart, Anaconda’s answer file, from a seed ISO labelled &lt;code&gt;OEMDRV&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Mistakes in these files show up late. An empty username produced an empty &lt;code&gt;&amp;lt;Name&amp;gt;&lt;/code&gt; element that
Windows Setup rejected hours into an install. pykickstart splits lines the way &lt;code&gt;shlex&lt;/code&gt; does, so a
password ending in a backslash aborted the SteamOS install. Both are fixed, and the two generators
are pure functions with 26 unit tests between them.&lt;/p&gt;
&lt;h2 id=&quot;hard-problem-3-an-appliance-that-can-t-brick-itself&quot;&gt;Hard problem 3: an appliance that can’t brick itself
&lt;a class=&quot;heading-anchor&quot; href=&quot;#hard-problem-3-an-appliance-that-can-t-brick-itself&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Tendril’s users shouldn’t need to know what a kernel argument is. So the host is a Fedora bootc
image: the whole OS ships as a container image, an update is staged as a new deployment, and the
old one stays on disk. greenboot runs a required health check after each boot, and if it keeps
failing, the bootloader goes back to the previous image.&lt;/p&gt;
&lt;p&gt;The original design made every host change an image layer, with a first boot that built a custom
image for the detected GPUs. What shipped is simpler. The base image carries only what every
machine needs:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;toml&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;kargs&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;intel_iommu=on&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;amd_iommu=on&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;iommu=pt&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;    &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;rd.driver.pre=vfio-pci&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GPU binding happens at runtime instead: each GPU device in the domain XML is marked
&lt;code&gt;managed=&#39;yes&#39;&lt;/code&gt;, so libvirt moves the group to &lt;code&gt;vfio-pci&lt;/code&gt; when the VM starts. Image layers are kept
for the case that needs them. A vGPU host driver is a kernel module, so Tendril builds a derived
image on the box against the running image’s kernel, and the two can’t drift apart.&lt;/p&gt;
&lt;p&gt;The instructive bug was in the health check. It required the UI to answer HTTPS on port 443,
but the docs also describe running behind a reverse proxy with &lt;code&gt;TENDRIL_TLS=off&lt;/code&gt;, which serves plain
HTTP there. On such a box the check failed on every boot, and greenboot rolled back every OS update.
Nothing crashed. The box just never moved forward. An audit pass caught it in 0.23, and now the
check tries both:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;shellscript&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;for&lt;/span&gt;&lt;span&gt; _&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; in&lt;/span&gt;&lt;span&gt; $(&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;seq&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 1&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 30&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; do&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;  if&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; curl&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;ksS&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; /dev/null&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;-max-time&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; https://127.0.0.1/&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;     |&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt; curl&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;sS&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;o&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; /dev/null&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; -&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;-max-time&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 3&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; http://127.0.0.1:443/&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    echo&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;greenboot(tendril): healthy&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt;    exit&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;  fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-8 z-d-10&quot;&gt;  sleep&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; 2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;done&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A required health check gates every future update, and when it fails wrongly it fails quietly. It
has to be tested against every supported setup, not just the default. The network page has a
similar net: each change is a 60-second trial that reverts unless you keep it.&lt;/p&gt;
&lt;h2 id=&quot;status&quot;&gt;Status
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Tendril is at 0.24.0, pre-1.0 and licensed AGPL-3.0. The changelog lists 24 releases, dated July 4
to July 9, 2026; later commits are release plumbing and docs. The code is four crates, eight
binaries and 92 unit tests on a Fedora bootc 42 base. Images ship on three channels: &lt;code&gt;dev&lt;/code&gt;,
per-release, and &lt;code&gt;stable&lt;/code&gt;, which points at an already-released digest instead of rebuilding.
Release images are signed with cosign.&lt;/p&gt;
&lt;p&gt;The limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hardware validation is partial.&lt;/strong&gt; Detection and bind planning ran live on the RTX 2070 SUPER,
and the unattended Windows 11 and Bazzite installs were verified under nested KVM. vGPU, fleets,
PXE provisioning and remote play are code-complete but untested on a real multi-GPU rig.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GPU reset quirks aren’t handled.&lt;/strong&gt; The design doc lists the AMD reset bug, which can stop a VM
from restarting, as a risk; the code doesn’t handle it yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PCIe bridges count as foreign devices,&lt;/strong&gt; so a card can be reported as sharing a group when it
doesn’t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kernel-level anti-cheat won’t run,&lt;/strong&gt; even with the opt-in overlay that hides KVM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-machine is federation, not clustering:&lt;/strong&gt; no consensus and no live migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I’d do differently
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-i-d-do-differently&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The original design had a custom first-boot image and a controller/agent cluster with an elected
leader. Both were dropped. libvirt’s managed devices made the custom image unnecessary, and the
cluster fell to two facts: a station owns a physical GPU, so it can’t live-migrate, and a fleet that
never acts on its own doesn’t need consensus. Next time I’d write the constraints down first.&lt;/p&gt;
&lt;p&gt;I’d also think harder about shelling out. Dependency-free crates were worth it, but parsing command
output has a cost: &lt;code&gt;virsh domifaddr&lt;/code&gt; was read by fixed column, so a Windows adapter named
“Ethernet 2” hid the station’s IP. Next up is running the hardware checklist on a real multi-GPU
box, starting with PXE.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;The repository is private for now. The source, the docs (&lt;code&gt;FEDERATION.md&lt;/code&gt; on the fleet design,
&lt;code&gt;HARDWARE-TESTING.md&lt;/code&gt;, the tester checklist, and &lt;code&gt;PLAN.md&lt;/code&gt;, the original design) and the installer
ISO will be linked here when it goes public. Until then, &lt;a href=&quot;mailto:holden@ssalomon.com&quot;&gt;email me&lt;/a&gt; for
a walkthrough.&lt;/p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>A TrueNAS backup patch that rebuilds itself every boot and refuses to half-apply</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/truenas-truecloud-patch/"/>
        <id>https://arch.fyi/blog/posts/truenas-truecloud-patch/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/truenas-truecloud-patch/">&lt;p&gt;TrueNAS SCALE’s built-in backup, TrueCloud Backup, wraps restic: it can snapshot a dataset with ZFS before each run, deduplicates, runs on a schedule and shows progress in the UI. It only backs up to Storj, and in 2026 Storj raised its TrueNAS tier from $5 to $50 a month. I already pay for Backblaze B2. truenas-truecloud-patch extends TrueCloud Backup to B2 and any S3-compatible provider, and optionally lets it snapshot datasets that have children, which stock TrueNAS refuses. It works by patching TrueNAS’s internal middleware, which has no stability contract, so most of the engineering is about what happens when the patch meets a TrueNAS it didn’t expect.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Clone the repo onto a data pool and run &lt;code&gt;install.sh&lt;/code&gt; as root. B2 and S3 credentials appear in TrueCloud Backup’s dropdown, and &lt;code&gt;create_task.py verify&lt;/code&gt; confirms the patch is live. &lt;code&gt;update.sh&lt;/code&gt; moves between release tags and rolls back in one command, and a TrueNAS alert says when a release is out. There is deliberately no auto-update: on a box that patches itself at boot, a bad commit detonates on the next reboot.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/truenas-truecloud-patch-boot.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/truenas-truecloud-patch-boot.svg&quot; alt=&quot;A top-to-bottom boot sequence. middlewared starts with stock modules, pools import so the repository is reachable, and the PREINIT hook runs apply.sh. apply.sh asks whether this module&amp;#39;s assumptions about middleware still hold. If not, the module is skipped and TrueNAS is left stock. If so, it mounts an overlay on the /usr directories with its upper layer in /run, appends patch blocks after a marker line, and schedules a deferred unit that waits for boot to settle, re-applies and verifies the patch on the live path, then restarts middlewared so the patched modules load. Every hour, an alert source checks whether the patch&amp;#39;s stamps are present in the running process, and raises a CRITICAL alert if not.&quot; width=&quot;432&quot; height=&quot;1519&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;The patch is rebuilt on every boot, and any step that can fail leaves TrueNAS stock rather than half-patched.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h3 id=&quot;what-had-to-change&quot;&gt;What had to change
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-had-to-change&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;TrueNAS’s backend is a Python service, &lt;code&gt;middlewared&lt;/code&gt;; its web UI is a compiled Angular bundle. Reading both turned up three gaps.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The UI&lt;/strong&gt; filters the credential list through &lt;code&gt;filterByProviders&lt;/code&gt;, set to &lt;code&gt;[&quot;STORJ_IX&quot;]&lt;/code&gt;. The patch widens it to &lt;code&gt;[&quot;STORJ_IX&quot;,&quot;S3&quot;,&quot;B2&quot;]&lt;/code&gt; in the compiled JavaScript, after backing up the original.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B2 credentials.&lt;/strong&gt; Stock’s B2 remote class has no &lt;code&gt;get_restic_config()&lt;/code&gt;, so a B2 task falls through to the base class’s &lt;code&gt;NotImplementedError&lt;/code&gt;. The patch supplies one. Issue #1 showed that the credential layout differs between releases:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;python&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;def&lt;/span&gt;&lt;span class=&quot;z-l-7 z-d-6&quot;&gt; _tc_get_restic_config&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;task&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    p&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; task&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;credentials&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;provider&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    if&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; not&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; isinstance&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; dict&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;        #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; TrueNAS &amp;lt;= 24.10: provider is the type string (&amp;quot;B2&amp;quot;) and the&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;        #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; account/key live in the credential&amp;#39;s attributes dict. 25.04+&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt;        #&lt;/span&gt;&lt;span class=&quot;z-l-5 z-d-4&quot;&gt; moved them into a provider dict.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;        p&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span&gt; task&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;credentials&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;attributes&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;B2_ACCOUNT_ID&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; p&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;account&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;B2_ACCOUNT_KEY&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; p&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;key&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;B2RcloneRemote&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get_restic_config&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; staticmethod&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;_tc_get_restic_config&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;B2RcloneRemote&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;restic&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; =&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; True&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The repository URL&lt;/strong&gt; came out with a stray leading slash and a slash separator, where restic 0.16 wants &lt;code&gt;b2:bucket:path&lt;/code&gt;. A wrapper rewrites it, and does nothing when the URL is already right.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-overlayfs&quot;&gt;Why overlayfs
&lt;a class=&quot;heading-anchor&quot; href=&quot;#why-overlayfs&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;TrueNAS is an appliance: &lt;code&gt;/usr&lt;/code&gt; is read-only and an update replaces it wholesale. So &lt;code&gt;apply.sh&lt;/code&gt; mounts an overlay filesystem over the few middleware directories it touches. An overlay stacks a writable upper directory on a read-only lower one; reads fall through to the original unless the upper layer holds its own copy. Here the upper layer lives in &lt;code&gt;/run&lt;/code&gt;, which is RAM-backed. The patched files exist only for the current boot, nothing under &lt;code&gt;/usr&lt;/code&gt; is ever written, and undoing the patch is an unmount.&lt;/p&gt;
&lt;p&gt;Persistence comes from the two things an update doesn’t replace: the repo lives on a data pool, and the boot hook is registered in TrueNAS’s configuration database as a PREINIT init script. The first boot after an update is just a boot: the overlay goes back up and the patch re-applies to whatever middleware is now installed.&lt;/p&gt;
&lt;p&gt;Each patch is a Python block appended to a stock module after a marker line, so re-applying and reverting both start by truncating at the marker. CI &lt;code&gt;compile()&lt;/code&gt;s every block, because a syntax error there would break the box at boot.&lt;/p&gt;
&lt;h3 id=&quot;staying-safe-across-updates&quot;&gt;Staying safe across updates
&lt;a class=&quot;heading-anchor&quot; href=&quot;#staying-safe-across-updates&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;The patch writes down what it assumes about middleware: each function it wraps, whether it is async, and the parameters it passes. &lt;code&gt;tools/compat.py&lt;/code&gt; checks that list twice. CI runs it daily against iXsystems’ unreleased branches and files a bug report while a breaking change is still in beta, and &lt;code&gt;apply.sh&lt;/code&gt; runs it at every boot, refusing to apply any module whose assumptions fail. An unapplied module means stock TrueNAS: the feature is missing, but nothing is half-patched. The two modules also retire independently when TrueNAS ships either feature natively.&lt;/p&gt;
&lt;p&gt;TrueNAS 26 put this to the test. It rewrote the cloud backup code from async to synchronous and deleted the ZFS methods the nested module called; either change alone would have broken backups. The obvious replacements, the public &lt;code&gt;pool.dataset.query&lt;/code&gt; family, turned out to hide the datasets TrueNAS considers its own: 84 of 270 on a real pool, including live app data. Staging from that view would have silently left them all out. The module now follows one rule: read the truth from ZFS, make changes through middleware.&lt;/p&gt;
&lt;h2 id=&quot;patched-on-disk-is-not-patched-in-the-process&quot;&gt;Patched on disk is not patched in the process
&lt;a class=&quot;heading-anchor&quot; href=&quot;#patched-on-disk-is-not-patched-in-the-process&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;My first model of the boot order was wrong. I assumed PREINIT scripts ran before middlewared; they are run &lt;em&gt;by&lt;/em&gt; middlewared, after it has imported the stock modules, and it never re-imports. So B2 support silently reverted on every reboot, while &lt;code&gt;verify&lt;/code&gt; read a status file that only knew what was on disk and said all was well. The fix was a deferred restart, whose first version fired two seconds into TrueNAS’s own boot work and killed the apps startup. That release took 54 apps down. &lt;code&gt;wait_restart.sh&lt;/code&gt; now waits for systemd’s boot queue to drain and Docker’s state machine to settle, then restarts middlewared once.&lt;/p&gt;
&lt;p&gt;On 2026-08-19 it failed in a new way. &lt;code&gt;apply.sh&lt;/code&gt; mounted its overlay at 16:41:56 and logged every step OK. Four seconds later another boot hook ran &lt;code&gt;systemd-sysext refresh&lt;/code&gt;, which unmerged and remerged &lt;code&gt;/usr&lt;/code&gt; and took the overlay with it. The deferred restart loaded stock modules, and every B2 task failed for nineteen hours while &lt;code&gt;apply.log&lt;/code&gt; said the patch was active.&lt;/p&gt;
&lt;p&gt;Hook ordering can’t fix that, because middlewared itself merges a sysext over &lt;code&gt;/usr&lt;/code&gt; at runtime when it configures NVIDIA support for Docker. So the restart script now re-applies and checks the live path right before it restarts. And “is the patch loaded?” is asked where it can be answered exactly: inside middlewared. The patch stamps the objects it replaces, and an hourly alert source looks for the stamps:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;python&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;try&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    from&lt;/span&gt;&lt;span&gt; middlewared&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;plugins&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;cloud_backup&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;restic&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; import&lt;/span&gt;&lt;span&gt; get_restic_config&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;except&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; Exception&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; False&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt; not&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; getattr&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;get_restic_config&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;_truecloud_patched&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; False&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;    return&lt;/span&gt;&lt;span class=&quot;z-l-1 z-d-2&quot;&gt; False&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A missing stamp raises a CRITICAL TrueNAS alert, and turning off update alerts doesn’t silence it.&lt;/p&gt;
&lt;h2 id=&quot;a-snapshot-option-that-would-have-lied&quot;&gt;A snapshot option that would have lied
&lt;a class=&quot;heading-anchor&quot; href=&quot;#a-snapshot-option-that-would-have-lied&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Stock TrueNAS refuses “Take Snapshot” on any dataset with children, which rules out every pool running Apps, since each app is its own dataset. I’d already been burned by a backup that stayed green for months while missing 622 GB, so the guard’s reason interested me more than its inconvenience. ZFS doesn’t expose child datasets through a parent’s &lt;code&gt;.zfs/snapshot&lt;/code&gt; directory, so without the check restic walks a near-empty tree and reports SUCCESS. The guard is right. It has to be replaced with a real traversal, not removed.&lt;/p&gt;
&lt;p&gt;So after the recursive snapshot, the patch bind-mounts every descendant’s own snapshot directory into a staging tree that mirrors the original layout, and points restic there. If any descendant can’t be staged, the backup fails, and a post-mount check asserts that every target is mounted and the root isn’t empty. The guard is relaxed last, so a partial apply leaves it in place. Datasets with no file tree to stage, like the legacy-mountpoint ones behind that 622 GB, are skipped and logged by name, not dropped silently.&lt;/p&gt;
&lt;p&gt;Recursive snapshots exposed something stock never had to handle: its cleanup deletes only the parent snapshot, which on a 250-dataset pool orphaned 249 snapshots every successful run. The patch now owns the lifecycle, and ZFS pushed back. Reading a snapshot directory automounts it, by default for 300 seconds after the last access, so whatever restic read last is still busy when the sweep runs. Unmount-and-retry clears about 255 of 256; the rest is recorded in a sidecar file and reclaimed next run. The sidecar lives in &lt;code&gt;/run&lt;/code&gt;, which a reboot wipes, so a garbage collector also finds leftovers by name.&lt;/p&gt;
&lt;p&gt;On a 252-dataset pool, a scheduled backup built a 173-mount staging tree and finished in 18 minutes 14 seconds with zero orphaned snapshots. The same backup reading live files had stalled at 74% for over 12 hours.&lt;/p&gt;
&lt;h2 id=&quot;the-55-day-estimate&quot;&gt;The 55-day estimate
&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-55-day-estimate&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Without a cache path on the task, TrueNAS runs restic with &lt;code&gt;--no-cache&lt;/code&gt;, which re-reads repository metadata from the provider on every run. The first backup of a 564 GB dataset was estimated at 55 days. &lt;code&gt;create_task.py&lt;/code&gt; gained &lt;code&gt;--cache-path&lt;/code&gt; and warns when it’s missing.&lt;/p&gt;
&lt;h2 id=&quot;status-and-limitations&quot;&gt;Status and limitations
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status-and-limitations&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;v0.7.0 (July 2026) is the current stable release. v0.8.0, with the re-apply before restart and the in-process alert, is at its third release candidate.&lt;/li&gt;
&lt;li&gt;Requires TrueNAS SCALE 24.10 or newer. The daily matrix passes 24.10, 25.04, 25.10 and 26.0.0-BETA.3, and flags iXsystems’ development branch for 27 as broken; I’m not chasing that until it settles.&lt;/li&gt;
&lt;li&gt;Verified on real hardware running 25.10.4: three B2 tasks run end to end with no leftover snapshots or mounts, and a child-dataset file restored with a matching MD5. On a TrueNAS 26 beta VM: a 274-snapshot backup of a 292-dataset pool and a byte-identical restore from four levels deep.&lt;/li&gt;
&lt;li&gt;About 350 tests. CI runs shellcheck, &lt;code&gt;bash -n&lt;/code&gt;, ruff and pytest on Python 3.11 to 3.13, on both a self-hosted Gitea runner and GitHub.&lt;/li&gt;
&lt;li&gt;It patches internal APIs, so “fail-safe” means the box stays up, not that backups keep running. 24.10 and 25.04 pass the static checks, but nobody has run a backup on them. Each boot costs one middlewared restart, and a backup fired in the short window before it fails once.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I’d do differently
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-i-d-do-differently&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;On 2026-07-13 I cut twelve releases in one day, several fixing the one before, and with update alerts live each one interrupted every user. An alert people learn to dismiss is worse than none, because one day it carries a security fix. Now CI only publishes a stable tag on a commit a release candidate already points at, and candidates are invisible to &lt;code&gt;update.sh&lt;/code&gt; and the alert. I’d build that gate before the alert, not after.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Repository: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/truenas-truecloud-patch&quot;&gt;github.com/sudolulo/truenas-truecloud-patch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/truenas-truecloud-patch/blob/main/docs/how-it-works.md&quot;&gt;How it works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/truenas-truecloud-patch/blob/main/docs/nested-snapshots.md&quot;&gt;Nested-dataset snapshots&lt;/a&gt;, including how to check a backup actually contains data&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/truenas-truecloud-patch/blob/main/docs/verification.md&quot;&gt;What has actually been run&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>Picking the few photos that teach a face recognizer the most</title>
        <published>2026-09-21T00:00:00+00:00</published>
        <updated>2026-09-21T00:00:00+00:00</updated>
        
        <author>
          <name>Holden Salomon</name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://arch.fyi/blog/posts/winnow/"/>
        <id>https://arch.fyi/blog/posts/winnow/</id>
        
        <content type="html" xml:base="https://arch.fyi/blog/posts/winnow/">&lt;p&gt;Frigate, the open-source NVR, can put names to faces, but only for people it has training images of. The people I most wanted it to know rarely walk past a camera, so it never collects enough varied shots of them. My Immich photo library already knows them by name, but uploading everything would be close to useless: a library is full of bursts, repeats and the same few angles, and a recognizer learns little from the tenth copy of one condition. winnow picks the handful of photos that differ most. By default it keeps at most five images per person, chosen from a pool of up to 3,000 candidates, and registers them through Frigate’s API.&lt;/p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What it does
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-it-does&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;You point a container at Immich and Frigate and give it a cron schedule. Each run goes through every named person, chooses candidates, crops each face from the original photo, aligns it to the 112×112 layout ArcFace-style recognizers expect, and uploads it.&lt;/p&gt;
&lt;p&gt;A tracker remembers which photos were uploaded or rejected, so none is processed twice, and maps each Frigate filename back to its Immich asset. That enforces the rule I care about most: winnow only deletes or replaces files it uploaded itself, never images someone curated by hand in Frigate’s UI.&lt;/p&gt;
&lt;p&gt;It ships as four Docker images: NVIDIA CUDA (&lt;code&gt;:latest&lt;/code&gt;), AMD ROCm (&lt;code&gt;:rocm&lt;/code&gt;), Intel through OpenVINO (&lt;code&gt;:intel&lt;/code&gt;), and a CPU image (&lt;code&gt;:cpu&lt;/code&gt;) for both amd64 and arm64.&lt;/p&gt;
&lt;p&gt;winnow began as a fork of Sebastian’s &lt;a rel=&quot;external&quot; href=&quot;https://github.com/ds-sebastian/if_curator&quot;&gt;if_curator&lt;/a&gt;, an interactive tool that already had the embedding and clustering core. I rewrote it to run unattended in Docker and added near-duplicate removal, the whole Frigate side (delivery, the tracker, Frigate-scored replacement) and the multi-accelerator builds.&lt;/p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works
&lt;a class=&quot;heading-anchor&quot; href=&quot;#how-it-works&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;figure class=&quot;figure&quot;&gt;&lt;a class=&quot;figure-frame&quot; href=&quot;https://arch.fyi/blog/diagrams/winnow-selection.svg&quot; title=&quot;Open the full-size image&quot;&gt;&lt;img src=&quot;https://arch.fyi/blog/diagrams/winnow-selection.svg&quot; alt=&quot;A top-to-bottom pipeline. Immich photos tagged with one person are filtered for age and upload history, pass a quality gate for blur, infrared, exposure, face size and detection confidence, are embedded with ArcFace, lose their near-duplicates, and are selected by K-Medoids and then farthest-point sampling. Each selected photo is cropped and aligned from the original. If the person is below the cap, Frigate is asked whether it already recognizes the face easily: if not, the crop is registered, if so it is skipped. At the cap, a more novel crop replaces the most redundant winnow-uploaded image, and anything else is skipped.&quot; width=&quot;652&quot; height=&quot;1381&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;&gt;&lt;/a&gt;&lt;figcaption&gt;Cheap filters run first; Frigate’s own opinion of the face is the last gate before anything is uploaded.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The pool narrows in stages, cheapest first. Photos older than ten years, already uploaded or previously rejected drop out before any download. Preview thumbnails then pass a quality gate: Laplacian variance for blur (a sharp image has strong edges, so its second derivative varies a lot), near-identical colour channels for grayscale or infrared, mean brightness for exposure, a minimum face width of 90 pixels, and a minimum detection confidence of 0.7.&lt;/p&gt;
&lt;p&gt;Survivors are cropped to the person’s face using Immich’s bounding box, so a group photo embeds the right person, and InsightFace’s ArcFace model turns each crop into a 512-dimension vector.&lt;/p&gt;
&lt;h3 id=&quot;why-embeddings-and-why-these-algorithms&quot;&gt;Why embeddings, and why these algorithms
&lt;a class=&quot;heading-anchor&quot; href=&quot;#why-embeddings-and-why-these-algorithms&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;ArcFace is trained so that photos of the same person land close together and photos of different people land far apart. Within one person’s cloud of points, the distance that remains is mostly what a camera varies: pose, light, expression, glasses, age. So the cosine distance between two embeddings of one person measures how differently a recognizer sees the two photos. That is the right yardstick for diversity; timestamps and pixel differences are not.&lt;/p&gt;
&lt;p&gt;Selection runs in three steps.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Near-duplicate removal.&lt;/strong&gt; Sort by sharpness, then greedily drop any photo within 0.20 cosine distance of one already kept. Burst shots sit around 0.01 to 0.05. This keeps the sharpest frame of each burst and stops a dense knot of near-identical frames from pulling a cluster centre toward itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;K-Medoids.&lt;/strong&gt; Group the embeddings into &lt;em&gt;k&lt;/em&gt; clusters and take each cluster’s medoid, the real photo nearest the middle of its group. (k-means would return an averaged vector that isn’t a photo at all.) Medoids guarantee one representative per natural “look”, such as the years with a beard and the years without. Initialisation is deterministic and the swap search uses a fixed seed, so reruns agree.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Farthest-point sampling (FPS).&lt;/strong&gt; Fill any remaining slots by repeatedly taking the candidate whose distance to its &lt;em&gt;nearest&lt;/em&gt; selected photo is largest. This greedily spreads the picks apart and reaches the outliers that clustering averages away. Candidates Immich detected with confidence below 0.85 get their distance boosted, by up to 30% at the 0.7 floor, because low confidence usually means a profile or a partly hidden face, which is what a doorway camera sees. In adaptive mode, selection stops once the best remaining candidate is closer than 20% of the median pairwise distance, so a person with little variety gets fewer images instead of padding.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One honest detail: &lt;em&gt;k&lt;/em&gt; is capped at the budget, so with the default cap of five the medoids usually fill every slot. FPS does its real work when the cap is raised, or on small pools where &lt;em&gt;k&lt;/em&gt; is limited to a third of the candidates.&lt;/p&gt;
&lt;h3 id=&quot;frigate-judges-novelty&quot;&gt;Frigate judges novelty
&lt;a class=&quot;heading-anchor&quot; href=&quot;#frigate-judges-novelty&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h3&gt;
&lt;p&gt;Before each upload, winnow sends the crop to Frigate’s recognize endpoint (about 200 ms). A high score means Frigate already recognizes this face easily, possibly thanks to hand-added images winnow can’t see. Below the cap, a candidate scoring higher than the most redundant image winnow uploaded is skipped as already covered. At the cap, a candidate replaces that image only if it scores lower. A first run has no model to score against, so scoring starts on the next one.&lt;/p&gt;
&lt;h2 id=&quot;the-gpu-image-that-quietly-ran-on-the-cpu&quot;&gt;The GPU image that quietly ran on the CPU
&lt;a class=&quot;heading-anchor&quot; href=&quot;#the-gpu-image-that-quietly-ran-on-the-cpu&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;InsightFace runs on ONNX Runtime, which picks an execution provider (CUDA, ROCm, OpenVINO or CPU) at startup, and winnow falls back to the CPU when no GPU provider loads. That is friendly on a desktop and dangerous in a container, because the failure never looks like a GPU problem. In production it looked like a crash loop: InsightFace on the CPU, out of memory on large libraries.&lt;/p&gt;
&lt;p&gt;The CUDA provider went missing in three separate ways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CUDA and cuDNN came from &lt;code&gt;nvidia-*&lt;/code&gt; pip packages whose libraries the dynamic loader couldn’t see. Registering those directories with &lt;code&gt;ldconfig&lt;/code&gt; at build time fixed it.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;insightface&lt;/code&gt; 1.0.1 started depending on the CPU &lt;code&gt;onnxruntime&lt;/code&gt; package, which ships the same compiled module as the GPU build, so whichever installs last wins. Marking them as conflicts in uv didn’t stop &lt;code&gt;uv sync&lt;/code&gt; installing both in parallel, and on GitHub Actions the CPU binary consistently won.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;onnxruntime-gpu&lt;/code&gt; 1.26 only loads its CUDA provider if three more NVIDIA pip packages are importable, and they weren’t declared.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The last fix is blunt on purpose. After the sync, the Dockerfile reinstalls the GPU build on its own, so there is no race left to lose:&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;docker&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;elif [ &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;$VARIANT&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;gpu&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt; ]; then \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    uv sync --frozen --no-dev --extra gpu &amp;amp;&amp;amp; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    ORT_GPU_VER=$(.venv/bin/python -c &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;import importlib.metadata; print(importlib.metadata.version(&amp;#39;onnxruntime-gpu&amp;#39;))&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;) &amp;amp;&amp;amp; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span&gt;    uv pip install --python .venv/bin/python --no-deps --reinstall &lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;onnxruntime-gpu==$ORT_GPU_VER&lt;/span&gt;&lt;span class=&quot;z-l-2 z-d-5&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span&gt;; \&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It’s worth the trouble: on an RTX 2070 SUPER the bundled benchmark embeds a face in 12.8 ms, about eight times the CPU’s throughput. The memory blow-up had a second cause, too: every candidate thumbnail was held at once, about 2.4 GB for a person with 472 candidates. Batches of 32 now cap that at roughly 256 MB.&lt;/p&gt;
&lt;h2 id=&quot;one-dockerfile-four-accelerators-two-architectures&quot;&gt;One Dockerfile, four accelerators, two architectures
&lt;a class=&quot;heading-anchor&quot; href=&quot;#one-dockerfile-four-accelerators-two-architectures&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;Each accelerator wants a different base: NVIDIA’s CUDA image, Ubuntu 22.04 for Intel (its GPU repository only publishes for that release), 26.04 for ROCm and CPU, and 24.04 for arm64. One Dockerfile picks its base from a two-part stage name (abridged):&lt;/p&gt;
&lt;pre class=&quot;giallo z-l-code z-d-code&quot; &gt;&lt;code data-lang=&quot;docker&quot;&gt;&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;ARG&lt;/span&gt;&lt;span&gt; VARIANT=gpu&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; --platform=$BUILDPLATFORM nvidia/cuda:12.9.2-cudnn-runtime-ubuntu24.04 &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; base-amd64-gpu&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; ubuntu:26.04 &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; base-amd64-rocm&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; ubuntu:22.04 &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; base-amd64-intel&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; ubuntu:26.04 &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; base-amd64-cpu&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; ubuntu:24.04 &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; base-arm64-cpu&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;ARG&lt;/span&gt;&lt;span&gt; TARGETARCH&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;giallo-l&quot;&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;FROM&lt;/span&gt;&lt;span&gt; base-${TARGETARCH}-${VARIANT} &lt;/span&gt;&lt;span class=&quot;z-l-9 z-d-8&quot;&gt;AS&lt;/span&gt;&lt;span&gt; build&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most of the bugs here were silent defaults:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Docker’s automatic &lt;code&gt;TARGETARCH&lt;/code&gt; argument is only in scope for &lt;code&gt;FROM&lt;/code&gt; lines unless a stage re-declares it. Inside &lt;code&gt;RUN&lt;/code&gt; it was simply empty, so a per-architecture branch never added the Python 3.13 repository.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;add-apt-repository&lt;/code&gt; starts a gpg-agent, which crashed under QEMU emulation in the arm64 build. Piping the key through &lt;code&gt;gpg --dearmor&lt;/code&gt; instead failed worse: gpg exits 0 on bad input, so apt quietly skipped the repository. The fix was the original command with a throwaway &lt;code&gt;GNUPGHOME&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A CUDA 13.3 base demanded NVIDIA driver 575 or newer and rejected hosts on 570, the stable driver at the time. The GPU image went back to CUDA 12.&lt;/li&gt;
&lt;li&gt;Building every variant in one CI job ran the runner out of disk and cancelled the ROCm and Intel builds. Each variant now builds in its own job, on its own runner.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;status-and-limitations&quot;&gt;Status and limitations
&lt;a class=&quot;heading-anchor&quot; href=&quot;#status-and-limitations&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;winnow is at v0.6.6 (June 2026), AGPL-3.0, on Python 3.13, and needs Immich 1.106+ and Frigate 0.16+. It has just over 100 unit tests, 33 of them for the selection algorithms, and GitHub Actions builds all four images and publishes them to GHCR. The NVIDIA path is the one with production hours on it.&lt;/p&gt;
&lt;p&gt;The limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The README labels it early development. Quality replacement and stale-mapping cleanup remove images from Frigate and aren’t yet proven at scale.&lt;/li&gt;
&lt;li&gt;Selection embeds Immich’s preview thumbnails, while the uploaded crop comes from the original. They usually agree, but heavy preview compression could shift an embedding.&lt;/li&gt;
&lt;li&gt;Frigate’s score compares against the &lt;em&gt;mean&lt;/em&gt; of a person’s training embeddings. A set split between frontal shots and profiles has its mean in between, so candidates from either side look more novel than they are.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I’d do differently
&lt;a class=&quot;heading-anchor&quot; href=&quot;#what-i-d-do-differently&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;p&gt;In v0.5.0 I moved the tracker from two JSON files to SQLite. What followed, per the changelog: 17 bug-fix releases in two days, for data-loss risks in the migration layer, primary-key conflicts and retry storms on a full disk. v0.6.0 went back to JSON. Atomic writes followed a day later, and main now adds a file lock for overlapping runs. For two files of asset IDs, a database bought nothing that &lt;code&gt;os.replace&lt;/code&gt; and &lt;code&gt;flock&lt;/code&gt; don’t. Next time I’d name what new storage solves before paying for a migration.&lt;/p&gt;
&lt;p&gt;Next, if Frigate’s API ever exposes per-image embeddings, winnow can measure diversity against the whole training set, hand-added images included, instead of treating the score as a proxy.&lt;/p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links
&lt;a class=&quot;heading-anchor&quot; href=&quot;#links&quot; aria-label=&quot;Link to this section&quot;&gt;#&lt;/a&gt;
&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Repository: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/winnow&quot;&gt;github.com/sudolulo/winnow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Setup, troubleshooting and FAQ: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/sudolulo/winnow/wiki&quot;&gt;the wiki&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Upstream project: &lt;a rel=&quot;external&quot; href=&quot;https://github.com/ds-sebastian/if_curator&quot;&gt;ds-sebastian/if_curator&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content>
        
    </entry>
</feed>
