<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Pioneering on PHP Boy Scout</title><link>https://phpboyscout.uk/categories/pioneering/</link><description>Recent content in Pioneering on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 05 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/categories/pioneering/index.xml" rel="self" type="application/rss+xml"/><item><title>Release trust without the framework</title><link>https://phpboyscout.uk/release-trust-without-the-framework/</link><pubDate>Sun, 05 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/release-trust-without-the-framework/</guid><description>&lt;img src="https://phpboyscout.uk/release-trust-without-the-framework/cover-release-trust-without-the-framework.png" alt="Featured image of post Release trust without the framework" /&gt;&lt;p&gt;A few days ago I shipped &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;afmpeg and ffmpeg-wasi&lt;/a&gt;, a way to run FFmpeg as a WebAssembly module straight from Go with nothing installed on the host. afmpeg fetches that wasm module at runtime and then&amp;hellip; runs it. A lovely trick, right up until you ask the obvious question: how does afmpeg know the wasm it just pulled off the internet is the one &lt;em&gt;I&lt;/em&gt; built, and not something swapped in on the way down?&lt;/p&gt;
&lt;p&gt;It has to check a signature. And I already had the machinery to do exactly that&amp;hellip; it was just bolted to the inside of go-tool-base.&lt;/p&gt;
&lt;p&gt;So I pulled it out. Two new modules, both public: &lt;a class="link" href="https://gitlab.com/phpboyscout/signing" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing&lt;/code&gt;&lt;/a&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-part-i-lifted-out"&gt;The part I lifted out
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;signing&lt;/code&gt; is the OpenPGP/WKD signing-and-verification model the rest of my tools already lean on. It&amp;rsquo;s the same code behind &lt;code&gt;gtb update&lt;/code&gt; when it checks its own releases, lifted out into a standalone module you can drop into any project. Verify a signed release, or sign your own, and you do it &lt;em&gt;without&lt;/em&gt; dragging the whole go-tool-base framework (and its dependency tree, which is not small) in behind it.&lt;/p&gt;
&lt;p&gt;That last bit is the whole reason it&amp;rsquo;s a separate module and not just a package. Go has a rule here that trips people up: when your code imports a package, you inherit that package&amp;rsquo;s &lt;em&gt;entire&lt;/em&gt; module dependency list&amp;hellip; the full &lt;code&gt;go.mod&lt;/code&gt;, not just the corner you actually touched. So lifting one tidy little package out of go-tool-base into a sub-package would still have handed every consumer viper, OpenTelemetry, the whole charm stack, the lot. Only a separate module, with its own minimal &lt;code&gt;go.mod&lt;/code&gt;, keeps that weight off your build. The core here leans on ProtonMail&amp;rsquo;s go-crypto and cockroachdb/errors, and nothing else.&lt;/p&gt;
&lt;h2 id="the-backend-you-inject"&gt;The backend you inject
&lt;/h2&gt;&lt;p&gt;Signing needs a key, and keys live in awkward places: a PEM file on disk, a YubiKey, AWS KMS, GCP, Azure, a Vault. The remote ones drag in heavy SDKs. The AWS KMS client &lt;em&gt;alone&lt;/em&gt; pulls in 57 modules, and I really didn&amp;rsquo;t fancy that sitting in the core where everyone pays for it whether they ever touch KMS or not.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;signing&lt;/code&gt; doesn&amp;rsquo;t implement those backends at all. It defines an interface (a thing that hands back a &lt;code&gt;crypto.Signer&lt;/code&gt;) and lets you inject whichever backend you actually use. A light &lt;code&gt;local&lt;/code&gt; backend (a PEM key on disk) ships in the box, as a sensible default and a worked example of the shape. The heavy ones are separate modules you opt into, one at a time.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/signing-aws-kms" target="_blank" rel="noopener"
 &gt;&lt;code&gt;signing-aws-kms&lt;/code&gt;&lt;/a&gt; is the first of them. It wraps a KMS-held key as a signer, so &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/" &gt;the private half never leaves AWS&lt;/a&gt; and every signature is a round-trip to the cloud. Blank-import it and it registers itself. GCP, Azure and Vault will follow the same pattern, and because each is its own module, your binary only ever carries the one you reached for. And if none of them fit? The interface is right there for you to write your own.&lt;/p&gt;
&lt;h2 id="the-proof-already-running"&gt;The proof, already running
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a library out looking for a user. ffmpeg-wasi signs its release assets in CI right now, with a KMS key driven through the go-tool-base CLI, and afmpeg verifies that signature against an embedded key before it hands a single byte to the runtime.&lt;/p&gt;
&lt;p&gt;No valid signature, no run.&lt;/p&gt;
&lt;p&gt;The thing that needed the trust and the thing that provides it are two separate projects, talking to each other through a signature.&lt;/p&gt;
&lt;p&gt;The verifying key isn&amp;rsquo;t only baked into afmpeg, either. It&amp;rsquo;s published to WKD on my own domain, so the anchor you check against lives somewhere my git host has no say over. A signature is only ever worth as much as the key you check it against, and &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;pinning that to my own domain rather than the platform that hosts my code&lt;/a&gt; is a deliberate bit of the posture, not an accident of wherever a file happened to be convenient to drop.&lt;/p&gt;
&lt;h2 id="where-it-leaves-things"&gt;Where it leaves things
&lt;/h2&gt;&lt;p&gt;Two more modules out in the world, both public, both documented over at &lt;a class="link" href="https://signing.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;signing.phpboyscout.uk&lt;/a&gt;. The selfish win is that afmpeg got to trust its own downloads without me reinventing a single thing to do it. The broader one is that the signing model I keep banging on about is no longer something you have to swallow my entire framework to use. Take the part you want, and leave the rest on the shelf.&lt;/p&gt;</description></item><item><title>The secret that wasn't on my branch</title><link>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</guid><description>&lt;img src="https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/cover-the-secret-that-wasnt-on-my-branch.png" alt="Featured image of post The secret that wasn't on my branch" /&gt;&lt;p&gt;My CI went red on a secret I&amp;rsquo;d never committed.&lt;/p&gt;
&lt;p&gt;Not a close call, not a near-miss I&amp;rsquo;d half-forgotten about. gitleaks, the secret scanner, failed a merge request of mine on a private key that was not on my branch, was not in my change, and as far as I could tell had nothing to do with me at all. The job was adamant. I was baffled. Somewhere in between was a lesson about what a secret scanner actually scans.&lt;/p&gt;
&lt;h2 id="prove-it-isnt-yours"&gt;Prove it isn&amp;rsquo;t yours
&lt;/h2&gt;&lt;p&gt;First rule of being accused: don&amp;rsquo;t get defensive, get evidence. The scanner pointed at a couple of commits carrying a test private key and a PEM block in a spec document. I genuinely didn&amp;rsquo;t recognise them, but &amp;ldquo;I don&amp;rsquo;t recognise it&amp;rdquo; is a feeling, not a fact, and feelings don&amp;rsquo;t reopen a pipeline.&lt;/p&gt;
&lt;p&gt;Git will tell you the truth if you ask it precisely. The question is: are these flagged commits actually part of my branch?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git merge-base --is-ancestor &amp;lt;flagged-sha&amp;gt; HEAD
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That asks &amp;ldquo;is this commit an ancestor of where I am?&amp;rdquo;. The answer came back no. The commits the scanner was choking on were not in my history. They weren&amp;rsquo;t mine.&lt;/p&gt;
&lt;p&gt;So whose were they? A bit of digging turned them up on a completely separate, still-unmerged branch, where someone (me, a few days earlier, on a different feature) had committed a throwaway test key and a PEM example in a spec, on purpose, as fixtures. Deliberate, harmless, and nowhere near the branch under review. And yet here they were, failing a merge request that had never touched them.&lt;/p&gt;
&lt;h2 id="what-gitleaks-actually-scans"&gt;What gitleaks actually scans
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I&amp;rsquo;d taken for granted. I assumed &lt;code&gt;gitleaks detect&lt;/code&gt; scanned &lt;em&gt;my change&lt;/em&gt;. It doesn&amp;rsquo;t. With no further instruction it scans the whole history reachable in the checkout it&amp;rsquo;s handed.&lt;/p&gt;
&lt;p&gt;And the checkout it&amp;rsquo;s handed is where the second half of the surprise lives. GitLab runners default to &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt;, which reuses the runner&amp;rsquo;s working directory between jobs rather than cloning fresh every time. It&amp;rsquo;s faster, and most of the time you never notice. But it means a shared runner accumulates refs from every branch it has ever built. My MR&amp;rsquo;s job happened to run on a runner that had, at some point, built that other branch, so the fixtures were sitting right there in the local object store, fair game for a scanner walking the whole graph.&lt;/p&gt;
&lt;p&gt;So gitleaks did exactly what I&amp;rsquo;d asked it to do, which was &amp;ldquo;scan everything&amp;rdquo;, and &amp;ldquo;everything&amp;rdquo; turned out to be a great deal more than my change. It walked the lot and dutifully reported fixtures from a branch I wasn&amp;rsquo;t even proposing to merge. The scanner wasn&amp;rsquo;t wrong. My idea of what it was looking at was.&lt;/p&gt;
&lt;h2 id="unblock-now-fix-properly-after"&gt;Unblock now, fix properly after
&lt;/h2&gt;&lt;p&gt;Two problems on two timescales. I needed the MR to merge today, and I needed this to never happen again. Those want different fixes.&lt;/p&gt;
&lt;p&gt;The immediate one: tell gitleaks those specific fixtures are known and intended. They&amp;rsquo;re test material, they&amp;rsquo;re meant to be there, so they go in the &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/75f87c5/.gitleaks.toml#L27-L28" target="_blank" rel="noopener"
 &gt;allowlist&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;internal/cmd/keys/keys_test\.go&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;docs/development/specs/2026-06-08-keys-mint-command\.md&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That unblocks the merge. It does nothing about the root cause, which is that the scan was looking at the wrong commits in the first place. Allowlisting individual false positives as they crop up is closing the stable door after the horse has bolted, one horse at a time, forever.&lt;/p&gt;
&lt;p&gt;The real fix lives in the shared CI component, not in any one repo. Scope the scan to the commits the merge request actually introduces (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/go-security.yml#L105-L125" target="_blank" rel="noopener"
 &gt;cicd v0.10.3&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;if [ -n &amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA&amp;#34; ]; then&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact \&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;--&lt;span class="l"&gt;log-opts=&amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;else&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;fi&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--log-opts&lt;/code&gt; is handed straight through to &lt;code&gt;git log&lt;/code&gt;, so that range, base-of-the-MR to tip, is the exact set of commits the merge request adds and nothing else. On a merge request the scan now sees only what you&amp;rsquo;re proposing to merge. Off a merge request (a plain branch or a tag pipeline) it falls back to the full scan, because there you genuinely do want the lot. The before-and-after in the job log tells the whole story: the entire accumulated history on one side, the handful of commits you actually wrote on the other.&lt;/p&gt;
&lt;h2 id="fixing-the-fix"&gt;Fixing the fix
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a tax on touching CI shell, and I paid it. The change went into the go, rust and tofu security templates. Go and tofu went green. Rust failed.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/rust-security.yml#L118-L145" target="_blank" rel="noopener"
 &gt;rust template&lt;/a&gt; had built its optional &lt;code&gt;--config&lt;/code&gt; flag the clever way, inline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;... &lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a command &lt;em&gt;argument&lt;/em&gt;, that substitution is harmless: its exit status is thrown away, so nobody cares that the test inside returns false when there&amp;rsquo;s no config. But when I rewrote the block and reached for the same pattern as an &lt;em&gt;assignment&lt;/em&gt;, &lt;code&gt;VAR=$(... &amp;amp;&amp;amp; ...)&lt;/code&gt;, it became a different animal. An assignment takes the exit status of the command substitution, and under &lt;code&gt;set -e&lt;/code&gt; a non-zero status anywhere aborts the job. So on every run where the config was empty, which was most of them, the test returned false, the assignment inherited that false, and &lt;code&gt;set -e&lt;/code&gt; killed the job stone dead. Same &lt;code&gt;$(...)&lt;/code&gt;, two completely different fates, decided entirely by whether it sat to the right of an &lt;code&gt;=&lt;/code&gt; or got handed to a command as an argument. Go and tofu never used the assignment form, so only rust fell down the hole.&lt;/p&gt;
&lt;p&gt;The fix was to stop being clever and write the boring &lt;code&gt;if&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Boring shell is good shell.&lt;/p&gt;
&lt;h2 id="what-a-scanner-is-actually-looking-at"&gt;What a scanner is actually looking at
&lt;/h2&gt;&lt;p&gt;The whole mess came from one unspoken assumption: that a tool called to scan &amp;ldquo;my change&amp;rdquo; was scanning my change. It was scanning a checkout, and a checkout on a shared runner is a much bigger, messier thing than the diff in front of you. None of the pieces were broken. gitleaks did its job, &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt; did its job, my fixtures were exactly where I&amp;rsquo;d left them. They just added up to a red pipeline that had nothing to do with the code I was trying to ship. I&amp;rsquo;d spent a good chunk of the day proving my innocence to a scanner that was only ever doing as it was told&amp;hellip; and the one thing I&amp;rsquo;d actually got wrong was being sure I already knew what it was looking at.&lt;/p&gt;</description></item><item><title>I filed a feature request into my own framework</title><link>https://phpboyscout.uk/a-feature-request-into-my-own-framework/</link><pubDate>Fri, 03 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-feature-request-into-my-own-framework/</guid><description>&lt;img src="https://phpboyscout.uk/a-feature-request-into-my-own-framework/cover-a-feature-request-into-my-own-framework.png" alt="Featured image of post I filed a feature request into my own framework" /&gt;&lt;p&gt;I&amp;rsquo;m building a tool called keryx, and the part of it that matters here is its studio: a browser app where the work happens, which saves everything you do into a git repository behind the scenes, the way a developer&amp;rsquo;s project lives in git with a history you can step back through.&lt;/p&gt;
&lt;p&gt;I wanted that repository to be able to live entirely in memory. Cloned, edited, committed and pushed without ever writing a working copy out to a disk, for the times when you can&amp;rsquo;t, or would rather not, leave a checkout sitting around on the machine. It sounds exotic, but it&amp;rsquo;s something git libraries genuinely support, and it&amp;rsquo;s exactly what a browser studio running on a server somewhere wants.&lt;/p&gt;
&lt;p&gt;Getting it working needed one small, awkward piece of plumbing in the middle. And a few lines into writing that piece, I stopped, because I realised I was writing it in the wrong repository.&lt;/p&gt;
&lt;h2 id="the-bridge-i-was-about-to-vendor"&gt;The bridge I was about to vendor
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the awkward bit. All of keryx&amp;rsquo;s file handling goes through &lt;code&gt;afero&lt;/code&gt;, the standard filesystem interface in the Go world, the thing you hand your code so it neither knows nor cares whether it&amp;rsquo;s talking to a real disk, a test fake, or memory. It&amp;rsquo;s the interface go-tool-base hands you for filesystem work. But an in-memory git repository, the kind &lt;a class="link" href="https://github.com/go-git/go-git" target="_blank" rel="noopener"
 &gt;go-git&lt;/a&gt; gives you with its &lt;code&gt;memfs&lt;/code&gt;, doesn&amp;rsquo;t speak &lt;code&gt;afero&lt;/code&gt;. It speaks go-billy&amp;rsquo;s filesystem interface instead. Two perfectly good filesystem abstractions, and a worktree on the wrong side of the gap from all my code.&lt;/p&gt;
&lt;p&gt;What I needed was an adapter: a bridge that makes a billy filesystem look like an &lt;code&gt;afero.Fs&lt;/code&gt;, so the studio&amp;rsquo;s existing file handlers work unchanged over a repo that lives entirely in RAM. Twenty minutes of work, maybe. The obvious move was to write it inside keryx and get on with my afternoon.&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s the move I caught myself making. Because a billy-to-afero bridge is not a keryx thing. It&amp;rsquo;s not even a studio thing. It&amp;rsquo;s a &lt;em&gt;general&lt;/em&gt; capability that any tool built on go-tool-base might want the moment it touches git. Vendor it in keryx and I&amp;rsquo;ve buried a reusable bit of plumbing inside one consumer, where it will drift away from the framework and get reinvented, slightly differently, in the next tool I build that needs it.&lt;/p&gt;
&lt;p&gt;The bridge belonged in the framework. So that&amp;rsquo;s where I put it.&lt;/p&gt;
&lt;h2 id="a-feature-request-against-myself"&gt;A feature request, against myself
&lt;/h2&gt;&lt;p&gt;I wrote the need up properly. Not a code comment, not a mental note, but an actual feature request, with a reference implementation sketched out, dropped into the go-tool-base repository as a document for the framework to act on.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s something slightly absurd about filing a feature request against your own project. The author and the customer are the same person. But that&amp;rsquo;s exactly what gives it its value. The most useful design input a framework gets is a real consumer hitting a real wall, and for once I was both: the person who maintains go-tool-base, and the person downstream of it who&amp;rsquo;d just discovered something it couldn&amp;rsquo;t yet do. The request wasn&amp;rsquo;t hypothetical or &amp;ldquo;wouldn&amp;rsquo;t it be nice&amp;rdquo;. It was &amp;ldquo;I am stuck on this right now, here is precisely what it can&amp;rsquo;t do yet.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;What came out the other side is &lt;code&gt;pkg/vcs/repo/aferobilly&lt;/code&gt;, a first-class part of the framework as of v0.22.0. Its own description is the clearest summary of what it is:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Package aferobilly adapts a go-billy/v5 Filesystem to an afero.Fs. It is the&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// pure, reusable bridge behind pkg/vcs/repo&amp;#39;s worktree-as-afero accessors, but&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// works for any billy filesystem (memfs, osfs, chroot).&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Alongside it, the worktree itself grew the accessors that hand you that view: &lt;code&gt;WorkFS()&lt;/code&gt; for a live afero handle, and &lt;code&gt;WithWorkFS()&lt;/code&gt; for an atomic sequence (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f71fe3bb1f9ebfb34c15440c58e1e2c518ca6a39/pkg/vcs/repo/worktree_fs.go#L39-L52" target="_blank" rel="noopener"
 &gt;&lt;code&gt;worktree_fs.go&lt;/code&gt;&lt;/a&gt;, and the &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f71fe3bb1f9ebfb34c15440c58e1e2c518ca6a39/pkg/vcs/repo/aferobilly/aferobilly.go#L1-L15" target="_blank" rel="noopener"
 &gt;adapter itself&lt;/a&gt;). keryx then consumed it like any other framework feature, and the in-memory studio fell into place.&lt;/p&gt;
&lt;h2 id="two-sessions-one-dependency"&gt;Two sessions, one dependency
&lt;/h2&gt;&lt;p&gt;The bit I&amp;rsquo;d actually recommend to anyone is what I did with my time while that got built.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t down tools and wait for the adapter. I handed the feature request to a separate agent session and let it build the framework feature from the spec, working in the go-tool-base repo, while my keryx session carried straight on with all the studio work that didn&amp;rsquo;t depend on the bridge. Two sessions running in parallel, deliberately sequenced around the one dependency between them: keryx needs the adapter, so the adapter session goes first, but only the &lt;em&gt;last&lt;/em&gt; mile of keryx actually waits on it. When go-tool-base cut the release with the adapter in it, keryx pulled the new version and the final piece slotted in.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a workflow the framework split makes possible. The thing that&amp;rsquo;s a shared capability gets built once, in its proper home, by one stream of work, while the thing that consumes it carries on in another. The dependency between them is real, so the order matters, but only at the very end.&lt;/p&gt;
&lt;h2 id="the-one-rule-that-came-with-it"&gt;The one rule that came with it
&lt;/h2&gt;&lt;p&gt;Upstreaming it also meant the tricky part got solved properly, once, with a warning attached, rather than learned the hard way in a consumer. The adapter is concurrency-safe by construction: it serialises every operation through a lock, so when that lock is the same mutex guarding the repo, a live &lt;code&gt;afero&lt;/code&gt; handle over the worktree is genuinely safe to share. But that safety has a sharp edge, and the package says so plainly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// A handle (and its open files) must NOT be used from inside a critical section&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// that already holds the same locker (the repo mutex is non-reentrant — that&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// would deadlock).&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Use the handle inside a &lt;code&gt;WithWorkFS&lt;/code&gt; callback and you&amp;rsquo;ll re-lock a non-reentrant mutex and hang yourself. That&amp;rsquo;s exactly the kind of footgun that, vendored in keryx, I&amp;rsquo;d have discovered at 11pm with a wedged process and no idea why. In the framework, it&amp;rsquo;s documented at the source, where the next consumer reads it before they trip over it.&lt;/p&gt;
&lt;h2 id="the-truest-test-of-a-framework"&gt;The truest test of a framework
&lt;/h2&gt;&lt;p&gt;Building a real product on your own framework is the best test of it, and this is what that actually looks like in practice. The test is sharper than &amp;ldquo;does it work&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;what does the product need that the framework doesn&amp;rsquo;t have yet&amp;rdquo;, and every real answer to that is a feature request waiting to be filed.&lt;/p&gt;
&lt;p&gt;The discipline is filing it against the framework instead of patching around it in the app. Do that, and the awkward bridge has exactly one home, the deadlock warning gets written down once, and the next tool I build inherits all of it for free. The customer was me. The feature request was real. And go-tool-base is better for my having been stuck.&lt;/p&gt;</description></item><item><title>There's no AI in my photo culler</title><link>https://phpboyscout.uk/no-ai-in-my-photo-culler/</link><pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/no-ai-in-my-photo-culler/</guid><description>&lt;img src="https://phpboyscout.uk/no-ai-in-my-photo-culler/cover-no-ai-in-my-photo-culler.png" alt="Featured image of post There's no AI in my photo culler" /&gt;&lt;p&gt;Before a wedding photographer can edit a single frame, there&amp;rsquo;s the cull: sitting down with three or four thousand photos from the day and deciding which are even worth keeping. The blurry ones, the ones where the flash fired into a mirror, the same moment shot eight times in a burst where only one frame is sharp. It&amp;rsquo;s mechanical, it&amp;rsquo;s exhausting, and it&amp;rsquo;s the first job krites does for Hailey.&lt;/p&gt;
&lt;p&gt;Every culling tool I looked at before building it leads with the same word. AI. AI culling, AI selects, trained on millions of weddings. So when I sat down to write krites&amp;rsquo; first pass, I assumed I&amp;rsquo;d be wiring up a model too. For the part that does the most work, it turns out, I didn&amp;rsquo;t need one.&lt;/p&gt;
&lt;p&gt;The shipped culler doesn&amp;rsquo;t load a single weight. It&amp;rsquo;s arithmetic, the sort a calculator could do if you were patient enough, and that&amp;rsquo;s a deliberate choice rather than a corner I cut. Here&amp;rsquo;s what&amp;rsquo;s actually under it.&lt;/p&gt;
&lt;h2 id="blur-is-the-variance-of-a-laplacian"&gt;Blur is the variance of a Laplacian
&lt;/h2&gt;&lt;p&gt;The first question for any frame is whether it&amp;rsquo;s in focus. You can answer it without knowing anything about what&amp;rsquo;s in the photo.&lt;/p&gt;
&lt;p&gt;A Laplacian is an edge detector. Run it over an image and it lights up wherever the brightness changes sharply, the crisp boundary between a dark suit and a white shirt, the line of an eyelash. A photo in focus is full of those sharp transitions; a soft or motion-blurred one has smeared them all into gentle gradients. So if you measure how much the edge response &lt;em&gt;varies&lt;/em&gt; across the frame, a sharp photo gives you a big spread of values and a blurry one gives you a flat, lifeless number. That single number is the focus score.&lt;/p&gt;
&lt;p&gt;In krites it&amp;rsquo;s a 3×3 kernel over the frame&amp;rsquo;s luma (the brightness channel, Rec. 601 weights), and the score is the variance of the response:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;lap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;lapCenter&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Sum the responses, sum their squares, and the variance falls out as &lt;code&gt;sumSq/n - mean*mean&lt;/code&gt;. No training data, no inference, and the same pixels always give the same answer. (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/quality/quality.go#L89-L118" target="_blank" rel="noopener"
 &gt;&lt;code&gt;quality.go&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;
&lt;h2 id="exposure-is-a-histogram"&gt;Exposure is a histogram
&lt;/h2&gt;&lt;p&gt;The second question is whether the exposure is salvageable. If a third of the frame is pure white, the highlights are blown and there&amp;rsquo;s no detail to bring back; if it&amp;rsquo;s mostly pure black, the shadows are crushed the same way.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s just counting. Walk the luma plane once, tally how many pixels sit at or above a near-white threshold and how many at or below a near-black one, divide by the total, and you&amp;rsquo;ve got two fractions: the blown-highlight proportion and the crushed-shadow proportion. A photographer cares about those two numbers directly, and a &lt;code&gt;for&lt;/code&gt; loop produces them (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/quality/quality.go#L120-L140" target="_blank" rel="noopener"
 &gt;&lt;code&gt;quality.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="two-photos-are-the-same-when-sixty-four-bits-agree"&gt;Two photos are the same when sixty-four bits agree
&lt;/h2&gt;&lt;p&gt;Then there are the bursts. A photographer holds the shutter through the first kiss and gets twelve nearly-identical frames; you want the sharpest one and the rest out of the way. To do that the tool has to know which frames are &amp;ldquo;the same shot&amp;rdquo;, and again you don&amp;rsquo;t need to understand the photo to tell.&lt;/p&gt;
&lt;p&gt;The trick is a perceptual hash, a difference hash to be exact. Shrink the image right down to a nine-by-eight grey thumbnail, then for each row note simply whether each cell is brighter than the one to its right. That&amp;rsquo;s sixty-four yes/no comparisons, packed into a sixty-four-bit number, a fingerprint of the picture&amp;rsquo;s broad light-and-dark structure that survives a resize, a small reframe or a touch of noise:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;grey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;hashW&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;grey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;hashW&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two fingerprints are compared by counting the bits that differ between them, the Hamming distance, which on a 64-bit integer is one CPU instruction (&lt;code&gt;bits.OnesCount64&lt;/code&gt;). A small distance means the frames look alike. krites only clusters &lt;em&gt;consecutive&lt;/em&gt; frames within that distance, so a run of similar shots merges into a burst but two unrelated photos that happen to rhyme don&amp;rsquo;t (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/dedup/dedup.go#L37-L89" target="_blank" rel="noopener"
 &gt;&lt;code&gt;dedup.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Best-of-burst is then the dullest line of code in the project: keep the sharpest frame in the cluster, demote the others from &lt;em&gt;keep&lt;/em&gt; to &lt;em&gt;maybe&lt;/em&gt;, and write down why.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reasons&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;near-duplicate of &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;bestFrame&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;&amp;#34; (kept the sharper frame)&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="signals-in-a-verdict-out"&gt;Signals in, a verdict out
&lt;/h2&gt;&lt;p&gt;None of those measurements decide anything on their own. A focus score of 50 is rejectable on one shoot and fine on another, because the numbers scale with resolution and content. So the signals feed a &lt;em&gt;profile&lt;/em&gt;, a small set of thresholds, and the profile turns them into a ruling: below the hard focus gate it&amp;rsquo;s a reject, below a softer floor it&amp;rsquo;s a maybe, blown past the exposure gates it&amp;rsquo;s a reject, otherwise it&amp;rsquo;s a keep. Every verdict carries its reasons in plain words, &amp;ldquo;out of focus (sharpness 32 below 50)&amp;rdquo;, because krites proposes and the human disposes (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/cull/cull.go#L71-L108" target="_blank" rel="noopener"
 &gt;&lt;code&gt;cull.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The seed thresholds for a wedding are just a starting point, written to config on &lt;code&gt;krites init&lt;/code&gt; and tuned from there:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMinSharpness&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// below this: rejected as out of focus&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedSoftSharpness&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// below this (but &amp;gt;= min): demoted to maybe&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMaxHighlights&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMaxShadows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedDedupDistance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The thresholds are the whole point of keeping them visible. &amp;ldquo;Suitable for a wedding album&amp;rdquo; is Hailey&amp;rsquo;s definition, not mine and not a model&amp;rsquo;s, and a number in a config file is something she can move (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/cull/profile.go#L9-L29" target="_blank" rel="noopener"
 &gt;&lt;code&gt;profile.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="where-the-models-do-belong"&gt;Where the models do belong
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not claiming AI has no place in this. Some of what a wedding photographer culls on genuinely needs a model: is this person mid-blink, is anyone actually looking at the camera, is the composition any good. Those are coming, and they&amp;rsquo;ll be model-backed when they do. The deliberate bit is that they sit &lt;em&gt;outside&lt;/em&gt; this deterministic core, behind an interface, opt-in. The maths that does the heavy lifting of the first pass never imports a model.&lt;/p&gt;
&lt;p&gt;That separation buys three things you lose the moment a neural net touches the hot path. It&amp;rsquo;s reproducible: the same frames in the same order always cull the same way, so a verdict is debuggable and a regression is catchable. It&amp;rsquo;s quick enough to run over four thousand frames on a laptop with no GPU. And it stays honest about what it knows, because a threshold you can read is a threshold you can argue with, which a confidence score from a black box never quite is.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;AI culling&amp;rdquo; makes for a better headline. But blur really is just a number, a duplicate really is just sixty-four bits, and the grim, mechanical first pass that stands between a photographer and their best photos comes down to arithmetic.&lt;/p&gt;</description></item><item><title>A stack trace is not an error message</title><link>https://phpboyscout.uk/a-stack-trace-is-not-an-error-message/</link><pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-stack-trace-is-not-an-error-message/</guid><description>&lt;img src="https://phpboyscout.uk/a-stack-trace-is-not-an-error-message/cover-a-stack-trace-is-not-an-error-message.png" alt="Featured image of post A stack trace is not an error message" /&gt;&lt;p&gt;The repair agent I&amp;rsquo;ve been building into go-tool-base narrates what it&amp;rsquo;s doing as it goes. It builds, it tests, it lints, it fixes, and it logs each step so I can watch it think. Mostly that log is a calm, readable trickle: tried this, that failed, reading the file, here&amp;rsquo;s the fix. Mostly.&lt;/p&gt;
&lt;p&gt;The moment a build or a lint step failed, the calm trickle turned into a wall of Go stack frames, the same forty lines of runtime gubbins over and over, burying the one line I actually wanted to read.&lt;/p&gt;
&lt;h2 id="two-different-things-we-both-call-the-error"&gt;Two different things we both call &amp;ldquo;the error&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The agent&amp;rsquo;s tools wrap their failures with &lt;a class="link" href="https://github.com/cockroachdb/errors" target="_blank" rel="noopener"
 &gt;cockroachdb/errors&lt;/a&gt;, which is a lovely library: it attaches a stack trace to an error the moment you create it, so when something goes wrong deep in the weeds you can see exactly how you got there. A failed &lt;code&gt;go build&lt;/code&gt; comes back as one of these rich, wrapped values, carrying its message &lt;em&gt;and&lt;/em&gt; its stack.&lt;/p&gt;
&lt;p&gt;The line that recorded the failure looked like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Tool execution failed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Looks fine. It is not fine. The logger is &lt;a class="link" href="https://github.com/charmbracelet/log" target="_blank" rel="noopener"
 &gt;charmbracelet/log&lt;/a&gt;, and when you hand a structured logger a cockroachdb error &lt;em&gt;value&lt;/em&gt; as a field, it renders the whole thing: message, wraps, types, and every frame of that attached stack. So every failed step, and during self-repair there are plenty, printed a full traceback at WARN. The signal, the actual build error, was in there somewhere, wearing a forty-line coat.&lt;/p&gt;
&lt;p&gt;The thing is, a stack trace and an error message are two different objects that we lazily both call &amp;ldquo;the error&amp;rdquo;. &lt;code&gt;err.Error()&lt;/code&gt; is the message: short, human, &amp;ldquo;lint issues found&amp;hellip;&amp;rdquo;. The value &lt;code&gt;err&lt;/code&gt; is the message &lt;em&gt;plus&lt;/em&gt; the evidence of where it came from. They serve different readers. The message is for whoever&amp;rsquo;s watching the loop run. The stack is for whoever&amp;rsquo;s debugging why the loop itself is broken. Hand the wrong one to the wrong reader and you&amp;rsquo;ve got either noise or a mystery.&lt;/p&gt;
&lt;h2 id="pick-a-reader-per-level"&gt;Pick a reader per level
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/570964c/pkg/chat/tools.go#L91-L99" target="_blank" rel="noopener"
 &gt;fix&lt;/a&gt; is to stop making one log line serve both:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Tool execution failed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Tool execution failure detail&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The message goes to WARN, where someone&amp;rsquo;s watching the agent work and just wants to know what failed. The full wrapped value, stack and all, goes to DEBUG, where someone&amp;rsquo;s gone looking for trouble and wants every frame. Turn the level up and the evidence is right there; leave it at the default and the loop reads like prose again.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a one-line change, give or take, and it lives in the shared chat tool-dispatch path rather than in the agent, so every tool-using client gets the quieter log for free. It&amp;rsquo;s the same loop I&amp;rsquo;d just &lt;a class="link" href="https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/" &gt;taught to respect the linter&lt;/a&gt;; apparently I was determined to make it both honest &lt;em&gt;and&lt;/em&gt; readable in the same fortnight.&lt;/p&gt;
&lt;h2 id="where-the-stack-belongs"&gt;Where the stack belongs
&lt;/h2&gt;&lt;p&gt;The stack trace was never the thing I needed to hide. cockroachdb/errors attaching it is exactly what I want; it&amp;rsquo;s the whole reason I use the library. The mistake was &lt;em&gt;where I let it surface&lt;/em&gt;. A trace dumped at WARN on every routine failure isn&amp;rsquo;t observability, it&amp;rsquo;s wallpaper, and wallpaper is what you stop seeing. Keep the loud version for the level where someone&amp;rsquo;s actually gone looking for it, and leave the everyday log alone. The stack was never the noise. Printing it on every line was.&lt;/p&gt;</description></item><item><title>A flag is not a setting</title><link>https://phpboyscout.uk/a-flag-is-not-a-setting/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-flag-is-not-a-setting/</guid><description>&lt;img src="https://phpboyscout.uk/a-flag-is-not-a-setting/cover-a-flag-is-not-a-setting.png" alt="Featured image of post A flag is not a setting" /&gt;&lt;p&gt;I was reviewing a change to rust-tool-base&amp;rsquo;s scaffolder when a word stopped me dead. &lt;code&gt;rtb generate config-field&lt;/code&gt;. I couldn&amp;rsquo;t have told you why in that first second&amp;hellip; I looked at it and just knew it was wrong.&lt;/p&gt;
&lt;p&gt;The verb there is &lt;code&gt;generate&lt;/code&gt;, and the verb is fine. It was the &lt;em&gt;noun&lt;/em&gt; that grated, &lt;code&gt;config-field&lt;/code&gt;, the name of the thing being made. Renaming it is a small change. It&amp;rsquo;s also a &lt;em&gt;breaking&lt;/em&gt; one, and a gut feeling is no reason to break someone&amp;rsquo;s command, so before I touched it I went and worked out what the instinct was reacting to.&lt;/p&gt;
&lt;h2 id="accurate-and-still-wrong"&gt;Accurate, and still wrong
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the awkward bit: &lt;code&gt;config-field&lt;/code&gt; is correct. The thing it makes really is a field on a config struct. If that name lived deep in a package, somewhere only another developer reading the source would ever trip over it, it&amp;rsquo;d be fine. The code&amp;rsquo;s audience is me, and &amp;ldquo;config field&amp;rdquo; is exactly what the code sees.&lt;/p&gt;
&lt;p&gt;But it doesn&amp;rsquo;t live deep in a package. It sits right out on the command line, on the one surface a user actually types, and a name out there has a different job. It has to telegraph what it does to someone who has never read a line of the source, in words a layperson would reach for. By that test &lt;code&gt;config-field&lt;/code&gt; fails, and not because it&amp;rsquo;s wrong. It fails because it&amp;rsquo;s right about the wrong thing. It describes the plumbing when all the user wants is to turn on the tap.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the rule I keep coming back to anywhere a person actually touches the tool: accurate is the floor, not the bar.&lt;/p&gt;
&lt;h2 id="what-the-noun-names"&gt;What the noun names
&lt;/h2&gt;&lt;p&gt;The right name falls out of what the thing actually is, so I went and pinned that down. rtb&amp;rsquo;s scaffolder makes three different things, and the noun is how you pick which:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a new subcommand&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a command-line argument on a command&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rtb&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# a field on the tool&amp;#39;s typed config&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A flag and a setting sound like cousins, but they answer two different questions: &lt;em&gt;where does the value come from&lt;/em&gt;, and &lt;em&gt;how long does it live&lt;/em&gt;. A flag is something the user types for a single run (a &lt;code&gt;clap&lt;/code&gt; argument, in Rust terms), like &lt;code&gt;deploy --region eu&lt;/code&gt; or &lt;code&gt;--dry-run&lt;/code&gt;. Transient, scoped to the one command. A setting is a typed field on the tool&amp;rsquo;s &lt;code&gt;AppConfig&lt;/code&gt;, read from its layered config: a file, the environment, or a one-off override on the CLI. Persistent, and tool-wide. (&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/eb13cd9/docs/concepts/flags-vs-settings.md" target="_blank" rel="noopener"
 &gt;The full contrast is its own doc now&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;Put the two side by side and the old name gives itself away. &lt;code&gt;flag&lt;/code&gt; says what the thing is &lt;em&gt;to a user&lt;/em&gt;. &lt;code&gt;config-field&lt;/code&gt; said what it is &lt;em&gt;to the code&lt;/em&gt;. One tells the truth at the surface; the other leaks an implementation detail you were never meant to care about.&lt;/p&gt;
&lt;h2 id="why-theyre-two-things-at-all"&gt;Why they&amp;rsquo;re two things at all
&lt;/h2&gt;&lt;p&gt;This is the bit that makes the rename honest rather than fussy, and it&amp;rsquo;s where rust-tool-base and go-tool-base part ways.&lt;/p&gt;
&lt;p&gt;In go-tool-base, a flag and a setting are pretty much the same object. cobra and viper (Go&amp;rsquo;s CLI and config libraries) fuse them: you bind a flag, viper reads its value from a config file or the environment, and you&amp;rsquo;re done. One persistent flag laid over a config bag. That&amp;rsquo;s no compromise, it&amp;rsquo;s an excellent convenience abstraction, gtb leans on it to the hilt, and for what it&amp;rsquo;s worth it&amp;rsquo;s the model I personally find the &lt;em&gt;simpler&lt;/em&gt; of the two. One mechanism, one thing to keep in your head.&lt;/p&gt;
&lt;p&gt;Rust won&amp;rsquo;t hand you that fusion, and it&amp;rsquo;s right not to. rtb&amp;rsquo;s config is a typed &lt;code&gt;AppConfig&lt;/code&gt; (built on figment, a Rust config library), not a dynamic &lt;code&gt;get_string(&amp;quot;key&amp;quot;)&lt;/code&gt; bag, so a command-line argument and a config field genuinely are different types with different lifetimes. Splitting them isn&amp;rsquo;t rtb being puritanical about it. It&amp;rsquo;s the shape Rust&amp;rsquo;s type system gives you, and the framework leans in and makes the most of it. The rtb version is, no argument, the more type-safe of the two.&lt;/p&gt;
&lt;p&gt;So neither is better. They suit different paradigms, and both do the job beautifully. But the knock-on for naming is concrete. Once a flag and a setting really &lt;em&gt;are&lt;/em&gt; two different things, calling one of them &lt;code&gt;config-field&lt;/code&gt; doesn&amp;rsquo;t just expose the plumbing, it tells a small lie: it implies a setting is the same kind of object as the struct field it happens to sit in. &lt;code&gt;setting&lt;/code&gt; tells the truth. This is the thing you configure once and the tool remembers, the sibling of &lt;code&gt;flag&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;(rtb has form here, mind. &amp;ldquo;flag&amp;rdquo; already pulls double duty: a runtime feature flag and a &lt;a class="link" href="https://phpboyscout.uk/two-kinds-of-feature-flag/" &gt;compile-time Cargo feature&lt;/a&gt; are two &lt;em&gt;more&lt;/em&gt; genuinely different things the framework keeps deliberately apart. Stretch one word across that many concepts and naming each one precisely stops being pedantry and becomes the only way anyone keeps them straight.)&lt;/p&gt;
&lt;h2 id="the-change"&gt;The change
&lt;/h2&gt;&lt;p&gt;So &lt;code&gt;config-field&lt;/code&gt; became &lt;code&gt;setting&lt;/code&gt;, and picked up its mirror image &lt;code&gt;remove setting&lt;/code&gt; to round out the trio of &lt;code&gt;command&lt;/code&gt; / &lt;code&gt;flag&lt;/code&gt; / &lt;code&gt;setting&lt;/code&gt;. It&amp;rsquo;s a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/commit/eb13cd9" target="_blank" rel="noopener"
 &gt;breaking change&lt;/a&gt;, &lt;code&gt;rtb generate config-field&lt;/code&gt; is gone for good, and it earned its keep. The cost is a line in a changelog. The return is a command surface that says what it means.&lt;/p&gt;
&lt;h2 id="name-the-tap"&gt;Name the tap
&lt;/h2&gt;&lt;p&gt;The gut reaction was right, but the gut reaction was never the point. The point is what it was reacting to: a name, out on a surface a human uses, describing the machinery instead of the job. &lt;code&gt;config-field&lt;/code&gt; was accurate. It still made the user stop and think about a struct field when all they wanted was to set something up and get on with it.&lt;/p&gt;
&lt;p&gt;Nobody turning on a tap wants to think about the pipework behind the wall. Name the tap.&lt;/p&gt;</description></item><item><title>The agent said SUCCESS. The linter disagreed.</title><link>https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/</guid><description>&lt;img src="https://phpboyscout.uk/the-agent-said-success-the-linter-disagreed/cover-the-agent-said-success-the-linter-disagreed.png" alt="Featured image of post The agent said SUCCESS. The linter disagreed." /&gt;&lt;p&gt;There&amp;rsquo;s a repair agent inside go-tool-base now. When you run &lt;a class="link" href="https://phpboyscout.uk/generate-a-command-from-a-script-or-a-sentence/" &gt;&lt;code&gt;gtb generate command&lt;/code&gt;&lt;/a&gt;, it doesn&amp;rsquo;t just spit out a file and wish you luck. An agent takes the generated code, builds it, runs the tests, and fixes whatever it broke, looping until the thing actually works (or until it&amp;rsquo;s tried the same fix five times and admits defeat). The whole point is that the generator hands you code that&amp;rsquo;s ready, not code that&amp;rsquo;s nearly ready and quietly now your problem.&lt;/p&gt;
&lt;p&gt;So it stung a bit when I realised the agent had been holding itself to a lower bar than I&amp;rsquo;d hold any junior to. And I was the one who&amp;rsquo;d set the bar.&lt;/p&gt;
&lt;h2 id="what-done-meant-to-the-agent"&gt;What &amp;ldquo;done&amp;rdquo; meant to the agent
&lt;/h2&gt;&lt;p&gt;The agent is a loop with real tools: it can build, test, read files, write files, tidy the module, and run golangci-lint. It works through them, and when it&amp;rsquo;s happy it replies with the word &amp;ldquo;SUCCESS&amp;rdquo; and the loop stops. On the Go side, the check is exactly that blunt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;strings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToUpper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;SUCCESS&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the whole gate (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/generator/verifier/agent.go#L149-L154" target="_blank" rel="noopener"
 &gt;&lt;code&gt;agent.go&lt;/code&gt;&lt;/a&gt;). There&amp;rsquo;s no clever verification on my end that the agent actually did its homework. It does the work, it tells me it&amp;rsquo;s done, and I believe it. Which is fine, as long as the agent and I agree on what &amp;ldquo;done&amp;rdquo; means.&lt;/p&gt;
&lt;p&gt;We didn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-instruction-that-made-lint-optional"&gt;The instruction that made lint optional
&lt;/h2&gt;&lt;p&gt;The agent decides it&amp;rsquo;s finished by following a numbered list in its system prompt. Here&amp;rsquo;s the line that did the damage:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="4"&gt;
&lt;li&gt;If there are lint issues, use &amp;lsquo;golangci_lint&amp;rsquo;.&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Read that the way the agent would. &amp;ldquo;If there are lint issues&amp;rdquo;&amp;hellip; well, how would it know? The only way to find out is to run golangci-lint. But the instruction makes running golangci-lint the thing you do &lt;em&gt;once you already know&lt;/em&gt; there are issues. It&amp;rsquo;s a chicken with no egg. And the SUCCESS condition at the bottom of the list never mentioned lint at all:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="7"&gt;
&lt;li&gt;When the project builds successfully and tests pass, reply with &amp;ldquo;SUCCESS&amp;rdquo;.&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;So the agent did the sensible thing, given its orders. It built the code, ran the tests, saw both go green, and declared victory. golangci-lint was sat right there in its toolbox, unused, because nothing ever told it the job wasn&amp;rsquo;t finished until lint was clean too. I&amp;rsquo;d handed it a linter and then written a prompt that let it walk straight past it.&lt;/p&gt;
&lt;p&gt;The galling part is that the linter was never the missing piece. The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/agent/tools.go#L539-L550" target="_blank" rel="noopener"
 &gt;&lt;code&gt;golangci_lint&lt;/code&gt; tool&lt;/a&gt; had been registered the whole time, and it even runs with &lt;code&gt;--fix&lt;/code&gt;, so it&amp;rsquo;ll quietly clear the trivial stuff and only surface what actually needs a decision. The capability was there. The instructions just never required it.&lt;/p&gt;
&lt;h2 id="the-fix-was-words-not-code"&gt;The fix was words, not code
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I find genuinely interesting. I didn&amp;rsquo;t add a check. There is no new gate in the Go. The &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/4834246/internal/generator/verifier/agent.go#L128-L134" target="_blank" rel="noopener"
 &gt;fix&lt;/a&gt; is four lines of &lt;em&gt;English&lt;/em&gt;:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;ol start="2"&gt;
&lt;li&gt;
&lt;p&gt;Run &amp;lsquo;go_build&amp;rsquo;, &amp;lsquo;go_test&amp;rsquo; and &amp;lsquo;golangci_lint&amp;rsquo; in the project directory&amp;hellip; Run all three; a clean build and passing tests do not imply clean lint.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Reply with &amp;ldquo;SUCCESS&amp;rdquo; only once &amp;lsquo;go_build&amp;rsquo;, &amp;lsquo;go_test&amp;rsquo; AND &amp;lsquo;golangci_lint&amp;rsquo; all pass with no errors and no reported issues.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

 &lt;/blockquote&gt;
&lt;p&gt;That&amp;rsquo;s it. Lint moves from a remediation step you reach for once you somehow already know there&amp;rsquo;s a problem, into the gate itself. &amp;ldquo;Done&amp;rdquo; now means three green lights, not two.&lt;/p&gt;
&lt;p&gt;It nags at me a little, that one. The reliability of an agent that writes and fixes real code came down to whether one sentence of instructions was precise enough. When your success criteria are a paragraph of prose, vagueness in that paragraph is a bug, the same as a vague type or an off-by-one. The spec just happens to be written in English, and the thing reading it is a language model that will cheerfully take the cheap reading if you leave it lying around. That&amp;rsquo;s the same lesson the &lt;a class="link" href="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/" &gt;goblin who wouldn&amp;rsquo;t stay dead&lt;/a&gt; taught me from the other direction: with these tools, what you say is what you get, and what you &lt;em&gt;don&amp;rsquo;t&lt;/em&gt; say is fair game.&lt;/p&gt;
&lt;h2 id="leave-it-better-not-just-building"&gt;Leave it better, not just building
&lt;/h2&gt;&lt;p&gt;The Boy Scout Rule is the whole reason this blog exists, and I&amp;rsquo;d quietly exempted the robot from it. &lt;a class="link" href="https://phpboyscout.uk/the-campsite-was-never-the-point/" &gt;&amp;ldquo;Leave the campsite cleaner than you found it&amp;rdquo;&lt;/a&gt; had become &amp;ldquo;leave it building&amp;rdquo;, which is not the same thing and never was. If I&amp;rsquo;m going to put an agent in the loop precisely so it tidies up after the generator, then &amp;ldquo;tidy&amp;rdquo; has to mean what it would mean for a person on my team. Build, test &lt;em&gt;and&lt;/em&gt; lint. No walking past the bin because nobody told you to pick it up.&lt;/p&gt;</description></item><item><title>The cobra hook I was sure I'd enabled</title><link>https://phpboyscout.uk/the-cobra-hook-i-was-sure-id-enabled/</link><pubDate>Wed, 24 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-cobra-hook-i-was-sure-id-enabled/</guid><description>&lt;img src="https://phpboyscout.uk/the-cobra-hook-i-was-sure-id-enabled/cover-the-cobra-hook-i-was-sure-id-enabled.png" alt="Featured image of post The cobra hook I was sure I'd enabled" /&gt;&lt;p&gt;It came out of an audit. I&amp;rsquo;d recently pointed a small army of review agents at the
whole go-tool-base codebase, back
&lt;a class="link" href="https://phpboyscout.uk/they-switched-it-off-while-it-was-fixing-my-code/" &gt;before that became a political problem&lt;/a&gt;,
and one of the findings was that a subcommand could quietly skip the framework&amp;rsquo;s own
start-up code. My first reaction was the dangerous one: surely not&amp;hellip; we switched that
on ages ago. So I asked for a second pair of eyes on the exact line.&lt;/p&gt;
&lt;p&gt;There was no line. I was certain I&amp;rsquo;d enabled it, but I had simply never done it.&lt;/p&gt;
&lt;h2 id="what-the-start-up-hook-is-for"&gt;What the start-up hook is for
&lt;/h2&gt;&lt;p&gt;go-tool-base is built on &lt;a class="link" href="https://github.com/spf13/cobra" target="_blank" rel="noopener"
 &gt;cobra&lt;/a&gt;, the library most
Go command-line tools are built on. In cobra, a command can carry a
&lt;code&gt;PersistentPreRunE&lt;/code&gt;: a function that runs &lt;em&gt;before&lt;/em&gt; the command itself, and that, the
name strongly implies, persists down to the command&amp;rsquo;s children. Think of it as the
&amp;ldquo;before you do anything, get the tool ready&amp;rdquo; step.&lt;/p&gt;
&lt;p&gt;go-tool-base uses exactly one of them, on the root command, to do all the
humdrum setup: load and merge configuration, set up logging, ask about telemetry
the first time you run, wire up the telemetry collector, and check whether there&amp;rsquo;s a
newer release to install. Everything the tool does afterwards leans on that having
happened. By the time your actual command runs, &lt;code&gt;props.Config&lt;/code&gt; is meant to be
populated and the collector is meant to exist.&lt;/p&gt;
&lt;p&gt;The reasonable assumption (the one I&amp;rsquo;d made, anyway) is that &amp;ldquo;persistent&amp;rdquo; means it cascades.
Define it once at the root and every &lt;code&gt;mytool foo bar&lt;/code&gt; three levels down gets it for
free.&lt;/p&gt;
&lt;h2 id="persistent-promises-less-than-it-says"&gt;&amp;ldquo;Persistent&amp;rdquo; promises less than it says
&lt;/h2&gt;&lt;p&gt;Here is the catch, and it is a good one to file away if you ever build a command
tree. cobra runs only the &lt;em&gt;nearest&lt;/em&gt; &lt;code&gt;PersistentPreRunE&lt;/code&gt; it finds, walking up from
the command you actually invoked. If a subcommand defines its own, that one runs and
the root&amp;rsquo;s does not. Not as well as. Instead of. There&amp;rsquo;s no warning and no error; the
child&amp;rsquo;s hook simply wins, and the parent&amp;rsquo;s is passed over in silence.&lt;/p&gt;
&lt;p&gt;So the moment any command below the root declared its own &lt;code&gt;PersistentPreRunE&lt;/code&gt;, the
entire start-up for that branch, the config, the logging, the telemetry, the update
check, would just not happen. &lt;code&gt;props.Config&lt;/code&gt; would be nil. The collector would be
nil. The first you&amp;rsquo;d hear of it is a nil-pointer panic a long way from the cause, or,
worse, no panic at all and a tool running happily unconfigured.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EnableTraverseRunHooks&lt;/code&gt; is cobra&amp;rsquo;s opt-in to the behaviour most people assume is
already the default: run every &lt;code&gt;PersistentPreRunE&lt;/code&gt; from the root down to the leaf, in
order. I&amp;rsquo;d assumed it was the default. It is not, and I&amp;rsquo;d never turned it on.&lt;/p&gt;
&lt;h2 id="a-landmine-nobody-had-stepped-on"&gt;A landmine nobody had stepped on
&lt;/h2&gt;&lt;p&gt;The saving grace was that nothing was actually broken yet. In go-tool-base&amp;rsquo;s own
command tree, the root is the only command that defines a persistent pre-run, so
&amp;ldquo;root to leaf&amp;rdquo; and &amp;ldquo;nearest only&amp;rdquo; happen to produce the identical result. The flag
being off changed nothing I could observe.&lt;/p&gt;
&lt;p&gt;The bug was latent. It was a trap laid for the first person to do something entirely
reasonable: add a &lt;code&gt;PersistentPreRunE&lt;/code&gt; to one of &lt;em&gt;their own&lt;/em&gt; subcommands. go-tool-base
is a framework other tools are built on, so that person was never going to be me. The
instant a downstream author did the obvious thing, their config and telemetry would
vanish for that branch of their tool and nothing would tell them why.&lt;/p&gt;
&lt;p&gt;That is the kind of bug I least like shipping. It compiled. It passed the tests. It
would have demoed perfectly. And it sat there waiting to hand a stranger a debugging
session with no breadcrumbs, for the crime of using a standard cobra feature the
obvious way.&lt;/p&gt;
&lt;h2 id="one-line-and-a-note-for-whoevers-next"&gt;One line, and a note for whoever&amp;rsquo;s next
&lt;/h2&gt;&lt;p&gt;The fix is the line I was so sure already existed
(&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/26eb355/pkg/cmd/root/root.go#L351-359" target="_blank" rel="noopener"
 &gt;root.go&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Run every parent PersistentPreRunE from root to leaf rather than only the&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// closest one. Without this, a downstream subcommand that defines its own&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// PersistentPreRunE silently shadows the root bootstrap (config load,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// telemetry, update check) for that subtree. With it set, the framework&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// bootstrap always runs first and a child hook runs after it.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EnableTraverseRunHooks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With it set, cobra runs the root start-up first and then the child&amp;rsquo;s hook, in order,
so a downstream command &lt;em&gt;adds to&lt;/em&gt; the setup instead of replacing it.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want to stop there, because the next author to add a child hook still
deserves to understand the ordering. So the change also drops a one-time debug line if
it spots any command in the tree carrying its own &lt;code&gt;PersistentPreRunE&lt;/code&gt;
(&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/26eb355/pkg/cmd/root/root.go#L397-406" target="_blank" rel="noopener"
 &gt;the same file&lt;/a&gt;),
saying out loud what&amp;rsquo;s going on:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;a downstream command defines its own PersistentPreRunE; &amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;it runs AFTER the framework bootstrap (config load, telemetry, update check), not instead of it&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And, belt and braces, the collector now defaults to a no-op so the few paths that do
legitimately return early, like &lt;code&gt;init&lt;/code&gt; and &lt;code&gt;help&lt;/code&gt;, still satisfy the &amp;ldquo;always
non-nil&amp;rdquo; promise the rest of the code relies on. The whole thing shipped with a pair
of regression tests that assert the bootstrap really does run when a child hook is
present, and that it runs first. It&amp;rsquo;s all written up in a short
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/26eb355/docs/development/specs/2026-06-12-bootstrap-prerun-traversal.md" target="_blank" rel="noopener"
 &gt;spec&lt;/a&gt;
and landed in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/26eb355" target="_blank" rel="noopener"
 &gt;one commit&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="trust-but-grep"&gt;Trust, but grep
&lt;/h2&gt;&lt;p&gt;There are two things worth taking away. The cobra one is portable: if you rely on
&lt;code&gt;PersistentPreRunE&lt;/code&gt; cascading down a command tree, set &lt;code&gt;EnableTraverseRunHooks&lt;/code&gt;,
because &amp;ldquo;persistent&amp;rdquo; means less than it sounds and the nearest hook wins by default.&lt;/p&gt;
&lt;p&gt;The other is the one I keep having to relearn. The settings I&amp;rsquo;m most certain about
are the ones I never check, precisely because the certainty is what stops me looking.
Somewhere along the line I&amp;rsquo;d promoted &amp;ldquo;I meant to&amp;rdquo; straight to &amp;ldquo;I did&amp;rdquo;, with nothing
in between&amp;hellip; and then defended it out loud before I&amp;rsquo;d even gone to look. A review agent is good
at exactly that blind spot: it has no memory of intending to do something, only the
code in front of it. The best thing the audit turned up wasn&amp;rsquo;t a clever bug. It was a
flag that was never there.
&lt;a class="link" href="https://phpboyscout.uk/the-campsite-was-never-the-point/" &gt;Leaving the campsite better than you found it&lt;/a&gt;
has to include the traps nobody&amp;rsquo;s stepped on yet.&lt;/p&gt;</description></item><item><title>When you hand the same key to every call</title><link>https://phpboyscout.uk/when-you-hand-the-same-key-to-every-call/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/when-you-hand-the-same-key-to-every-call/</guid><description>&lt;img src="https://phpboyscout.uk/when-you-hand-the-same-key-to-every-call/cover-when-you-hand-the-same-key-to-every-call.png" alt="Featured image of post When you hand the same key to every call" /&gt;&lt;p&gt;I was building a tutorial, the kind where the whole point is that the reader runs
every command and it just works. So I generated a fresh project with go-tool-base,
added a command, then added a command &lt;em&gt;underneath&lt;/em&gt; that command, and hit build. It
didn&amp;rsquo;t.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pkg/cmd/hello/cmd.go: props.ChildCmd undefined
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; (type *props.Props has no field or method ChildCmd)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;My own generator, in my own framework, had just written code that referenced a
thing that didn&amp;rsquo;t exist&amp;hellip; which is a special kind of embarrassing.&lt;/p&gt;
&lt;h2 id="a-bug-with-a-two-month-alibi"&gt;A bug with a two-month alibi
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;git blame&lt;/code&gt; walked me straight to the commit that
&lt;a class="link" href="https://phpboyscout.uk/middleware-for-cli-commands-not-just-web-servers/" &gt;introduced the command middleware system&lt;/a&gt;
back in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/8974154" target="_blank" rel="noopener"
 &gt;March&lt;/a&gt;.
Middleware here is the web-style idea of wrapping a command&amp;rsquo;s run function with
cross-cutting behaviour, timing, auth, recovery, that sort of thing. To wire it
in, &lt;a class="link" href="https://phpboyscout.uk/scaffolding-that-respects-your-edits/" &gt;the generator&lt;/a&gt;
started emitting this for a nested command:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCommandWithMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewCmdChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ChildCmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;where the line before had simply been:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewCmdChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The catch is that third argument. &lt;code&gt;props.ChildCmd&lt;/code&gt; is meant to be one of a set of
constants, but those constants are hand-declared for the framework&amp;rsquo;s &lt;em&gt;built-in&lt;/em&gt;
commands only (&lt;code&gt;UpdateCmd&lt;/code&gt;, &lt;code&gt;DocsCmd&lt;/code&gt;, and friends). The generator never declares
one for a user&amp;rsquo;s &lt;code&gt;child&lt;/code&gt; command, so the generated parent referenced a name that
nothing had ever declared. Undefined. Won&amp;rsquo;t compile.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part that should worry you more than the bug. It shipped in March and
nobody noticed until late May. Partly because it only bites &lt;em&gt;nested&lt;/em&gt; commands, a
command under another user command; top-level commands register by a different
path and were fine. But mostly because the generator&amp;rsquo;s tests checked the generated
code as &lt;em&gt;text&lt;/em&gt;, asserting that it contained the right strings, and never once ran
&lt;code&gt;go build&lt;/code&gt; on the result. CI was green for two months on code that could not
compile. We were grading the essay without ever reading it aloud.&lt;/p&gt;
&lt;h2 id="what-the-key-actually-was"&gt;What the key actually was
&lt;/h2&gt;&lt;p&gt;Once I stopped staring at the missing name, the real problem came into focus&amp;hellip;
and it wasn&amp;rsquo;t the missing constant at all.&lt;/p&gt;
&lt;p&gt;That third argument is a middleware &lt;em&gt;lookup key&lt;/em&gt;. The framework keeps a table of
middleware registered against each key, and the key tells it which to apply. It is
not an on/off switch and it is not optional, so the generator had to supply one at
&lt;em&gt;every&lt;/em&gt; registration site. It was being asked to guess, on every call, a value it
had no reliable way to produce for a user command.&lt;/p&gt;
&lt;p&gt;And the tell was sitting right there in the same generator: everywhere else, the
idiom was &lt;code&gt;props.FeatureCmd(&amp;quot;name&amp;quot;)&lt;/code&gt;, a function that derives a key from a string.
The nested-registration path was the one place that assumed a hand-declared
constant instead. One call site out of step with all the others.&lt;/p&gt;
&lt;p&gt;That is the actual lesson, and it has nothing to do with cobra or codegen. When you
find yourself threading the same derived value through every single call site, and
getting it wrong, the value is in the wrong place. The feature key was never the
caller&amp;rsquo;s business. It belonged to the command.&lt;/p&gt;
&lt;h2 id="changing-my-mind-about-cobra"&gt;Changing my mind about cobra
&lt;/h2&gt;&lt;p&gt;This is where I had to eat a helping of my own opinion.&lt;/p&gt;
&lt;p&gt;go-tool-base is built on &lt;a class="link" href="https://github.com/spf13/cobra" target="_blank" rel="noopener"
 &gt;cobra&lt;/a&gt;, the de-facto Go
library for building command trees, and I like it a great deal. I had &lt;em&gt;deliberately&lt;/em&gt;
not wrapped it. Every abstraction over a good library is a tax the reader pays, so
my standing rule was: use cobra directly, don&amp;rsquo;t hide it behind something of mine.&lt;/p&gt;
&lt;p&gt;The trouble is the middleware pattern kept growing, and the bigger it got the more
plainly &amp;ldquo;don&amp;rsquo;t abstract cobra&amp;rdquo; was a position I was holding past its evidence. The
very thing I&amp;rsquo;d refused to build was the thing the design had come to need. It
helped that, having recently
&lt;a class="link" href="https://phpboyscout.uk/why-we-left-github-for-gitlab/" &gt;moved the project to GitLab&lt;/a&gt;,
the version had reset to a &lt;code&gt;0.x&lt;/code&gt; prerelease, which makes a breaking change cheap.
The window to stop patching and do it properly was open, and not for long.&lt;/p&gt;
&lt;p&gt;Go&amp;rsquo;s composition model made it almost painless. You can embed a pointer to one
struct inside another and the outer type inherits all the inner one&amp;rsquo;s methods for
free, which is about as close to monkey-patching as a statically typed language
gets. So a &lt;code&gt;setup.Command&lt;/code&gt;
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/61844e6/pkg/setup/command.go#L22-L60" target="_blank" rel="noopener"
 &gt;became&lt;/a&gt;
a cobra command &lt;em&gt;plus&lt;/em&gt; the feature it belongs to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;Feature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FeatureCmd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FeatureCmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...*&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;range&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;			&lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RunE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;			&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RunE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RunE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Because &lt;code&gt;*cobra.Command&lt;/code&gt; is embedded, a &lt;code&gt;setup.Command&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; a cobra command for
every method cobra offers; the one place you need the raw pointer, you reach for
&lt;code&gt;.Command&lt;/code&gt;. The generated command now carries its own identity:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;child&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;RunE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FeatureCmd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;child&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and the parent registers it with nothing threaded through the call:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewCmdChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The feature key now lives on the command, derived from the command&amp;rsquo;s own name,
which is the one place the generator can &lt;em&gt;always&lt;/em&gt; produce it correctly. The
bug isn&amp;rsquo;t so much fixed as made unsayable: there&amp;rsquo;s no call site
left to write the wrong thing into. The wiring got cleaner on the way past, too,
each command&amp;rsquo;s run is wrapped exactly once with its own feature, instead of the old
recursive pass that re-applied the &lt;em&gt;parent&amp;rsquo;s&lt;/em&gt; feature down the whole subtree. And
the old free function stays on as a deprecated shim that just calls &lt;code&gt;Register&lt;/code&gt;, so
nothing downstream breaks before v1.0.&lt;/p&gt;
&lt;h2 id="changing-my-mind-about-the-tests"&gt;Changing my mind about the tests
&lt;/h2&gt;&lt;p&gt;The redesign was the satisfying fix. The test was the important one.&lt;/p&gt;
&lt;p&gt;The reason a non-compiling generator sailed through CI for two months is that its
tests read the generated source as text. A generator is a program that writes a
program, and we were checking that it wrote the expected words without ever asking
whether the words formed a working program. So the redesign shipped with
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/21a90e4/internal/generator/compile_integration_test.go" target="_blank" rel="noopener"
 &gt;a different kind of test&lt;/a&gt;,
one that scaffolds a real project, adds a nested command, and actually builds it.
Its own comment says the quiet part out loud:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;The previous test suite asserted file-content shapes but never tried to &lt;code&gt;go build&lt;/code&gt;
the generated module, so the nested-command path that referenced undefined
&lt;code&gt;props.&amp;lt;Name&amp;gt;Cmd&lt;/code&gt; symbols compiled cleanly in tests and broke only when downstream
users built their tools.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;It&amp;rsquo;s gated behind an integration flag, because it shells out to the Go toolchain
and that&amp;rsquo;s too heavy for every unit run, but it closes the exact gap that hid the
bug. The only &lt;a class="link" href="https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/" &gt;real test of a code generator&lt;/a&gt;
is whether its output compiles.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;Three times in one bug I had to change my mind. I&amp;rsquo;d decided cobra shouldn&amp;rsquo;t be
abstracted; the evidence said abstract it. I&amp;rsquo;d reached for the one-line patch; the
evidence said redesign. I&amp;rsquo;d trusted tests that read the generated code; the evidence
said build it. None of those were comfortable, and the version reset is the only
reason the timing was kind.&lt;/p&gt;
&lt;p&gt;Both technical lessons are worth keeping. When the same derived value is threaded
through every call, the abstraction is in the wrong place. And the only proof that
something which writes code actually works is to compile what it writes. But the one underneath
both, the one I apparently have to keep relearning, is simpler than either: don&amp;rsquo;t
get so attached to an implementation that you can&amp;rsquo;t change your mind when the
evidence says it doesn&amp;rsquo;t fit. The framework is better for it, and it now has a
composition seam to hang the features cobra doesn&amp;rsquo;t give us natively. A nested
command that wouldn&amp;rsquo;t build was just the thing that finally made me look.&lt;/p&gt;</description></item><item><title>The goblin that wouldn't stay dead</title><link>https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/</guid><description>&lt;img src="https://phpboyscout.uk/the-goblin-that-wouldnt-stay-dead/cover-the-goblin-that-wouldnt-stay-dead.png" alt="Featured image of post The goblin that wouldn't stay dead" /&gt;&lt;p&gt;Turn one, the player swings, the die comes up 20, and my AI dungeon master
narrates the goblin falling silent, leaving the player alone in the corridor.
Good. Turn two, another roll, a 6 this time, and the same dungeon master cheerily
has the goblin &amp;ldquo;dance back&amp;rdquo; out of the dark to take another swing. The goblin I&amp;rsquo;d
just watched die was up and fighting again, and the model didn&amp;rsquo;t so much as blink.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t feel cheated, or even surprised. I felt the small, familiar thud of &lt;em&gt;oh,
yeah, I forgot that bit.&lt;/em&gt; Because the model hadn&amp;rsquo;t gone rogue. It had done exactly
what a language model does. The gap was mine.&lt;/p&gt;
&lt;p&gt;This was the war story behind
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-4/" &gt;part four of the go-tool-base tutorial&lt;/a&gt;,
the AI dungeon master. The tutorial shows the clean, final design and quietly
moves on. It doesn&amp;rsquo;t show the three different ways I got it wrong first, which is
a shame, because the wrong turns are where the actual lesson is.&lt;/p&gt;
&lt;h2 id="why-a-dungeon-master-at-all"&gt;Why a dungeon master at all
&lt;/h2&gt;&lt;p&gt;A word on why I was even here. I was trying to prove the chat
component of the framework to myself. There&amp;rsquo;s a voice that pipes up whenever I
build anything in this space, &amp;ldquo;LangChain exists, who do you think you are?&amp;rdquo;, and
the answer I keep landing on is that LangChain is enormous and I wanted something
&lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;small enough to hold in your head&lt;/a&gt;.
The tutorial was the test: could a newcomer wire AI into a CLI with it and come
out the other side with something that actually &lt;em&gt;behaves&lt;/em&gt;?&lt;/p&gt;
&lt;p&gt;That last word is the whole problem. A tutorial has to leave you holding something
dependable, and dependability is the one thing AI fights you on. I also wanted it
to be fun, a thing someone might keep poking at after the tutorial ends, maybe
even the hook that gets a person other than me to use the framework. I batted hook
ideas around and liked none of them, until the obvious one landed: I run a
tabletop game on the odd weekend, so make the AI the dungeon master. Gamify the
thing. Then watch it raise the dead.&lt;/p&gt;
&lt;h2 id="strike-one-nothing-to-enforce"&gt;Strike one: nothing to enforce
&lt;/h2&gt;&lt;p&gt;The first version was the naive one. I gave the model a &lt;code&gt;roll&lt;/code&gt; tool, because the
one thing you absolutely cannot let a language model do is pick its own numbers,
and otherwise let it narrate freely. The conversation history carried from turn to
turn, so it &lt;em&gt;remembered&lt;/em&gt; the fight. I assumed remembering was enough.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t. Remembering and being held to it are different things. The history told
the model a goblin had died; nothing &lt;em&gt;stopped&lt;/em&gt; it writing the goblin back in when
the next turn&amp;rsquo;s narration wanted a bit of jeopardy. Memory is not a constraint.
The model will happily contradict its own past if you&amp;rsquo;ve given it room to, and I
had given it nothing but room.&lt;/p&gt;
&lt;h2 id="strike-two-a-tool-to-read-the-state"&gt;Strike two: a tool to read the state
&lt;/h2&gt;&lt;p&gt;The obvious fix, and I do mean obvious, the kind you reach for without thinking,
was to give the model a &lt;code&gt;state&lt;/code&gt; tool so it could check who was alive before it
narrated. Hand it the facts on request and surely it&amp;rsquo;ll stop making them up.&lt;/p&gt;
&lt;p&gt;What it actually did was dither. Handed a tool it could call to look things up, it
called it. And called it. And called it again, turning a turn over in its hands
without ever committing to an action, burning through its step budget on lookups
and leaving the player staring at nothing. I&amp;rsquo;d cured the lying by inventing
paralysis. A tool the model &lt;em&gt;can&lt;/em&gt; call is a tool it &lt;em&gt;will&lt;/em&gt; call, often instead of
doing the thing you actually wanted.&lt;/p&gt;
&lt;h2 id="strike-three-refereeing-its-own-dice"&gt;Strike three: refereeing its own dice
&lt;/h2&gt;&lt;p&gt;When I did get it reading state cleanly, the third failure crept in, and this one
was subtler. Once the model could see the goblin&amp;rsquo;s hit points, it started
&lt;em&gt;deciding&lt;/em&gt; the fight. It would read that the goblin had 12 HP and just narrate a
killing blow, hits and damage and all, without calling the &lt;code&gt;roll&lt;/code&gt; or &lt;code&gt;attack&lt;/code&gt;
tools at all. Why ask the dice when you can see the board and write whatever
outcome the story wants? Give a model enough context and it stops being a narrator
and starts being a referee, which is precisely the job I&amp;rsquo;d built tools to keep out
of its hands.&lt;/p&gt;
&lt;h2 id="the-fix-was-less-not-more"&gt;The fix was less, not more
&lt;/h2&gt;&lt;p&gt;Three failures, and notice the shape of my fixes: each one &lt;em&gt;added&lt;/em&gt; something. More
memory, then a tool, then more context. Every instinct said the model needed more
to work with. Every time, the extra capability was the new way to be wrong.&lt;/p&gt;
&lt;p&gt;So I went the other way. The truth lives in a plain Go struct that I own, not the
model. There&amp;rsquo;s no &lt;code&gt;state&lt;/code&gt; tool to dither on, because the loop simply prepends the
current state to every turn&amp;rsquo;s input, fresh, so the model never has to ask and
never gets to drift. The mechanics, the dice and the damage, live in Go functions
the model has to call, and the system prompt says in as many words that it must
not decide a hit or damage itself. The model is left with exactly one job:
narrate. The prose is its to invent. The maths, the state and the shape of the
result are not.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the line that turned three bugs into a feature. You don&amp;rsquo;t make a language
model reliable by giving it more to work with. You make it reliable by giving it
&lt;em&gt;less to be wrong about.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="the-freedom-i-chose-not-to-give-it"&gt;The freedom I chose not to give it
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a real tension in that, and I want to name it rather than pretend the
boxed-in version is the only true one. At my own table the rules are guidelines,
not guardrails. I ignore them, bend them, improvise, reach for the &amp;ldquo;rule of cool&amp;rdquo;
when the moment&amp;rsquo;s better for it. A great AI dungeon master would have that same
freedom, and a few out there genuinely do, &lt;a class="link" href="https://www.oldgregstavern.com/" target="_blank" rel="noopener"
 &gt;Old Greg&amp;rsquo;s Tavern&lt;/a&gt; is a lovely example
of how far the free-form version can go.&lt;/p&gt;
&lt;p&gt;But that freedom costs far more than a tutorial can spend, and it buys
unpredictability I was specifically trying to teach people to avoid. So I made a
deliberate trade: guardrails instead of guidelines. Simple, but not so simple it&amp;rsquo;s
boring. The player still gets a &amp;ldquo;not on rails&amp;rdquo; game, they can try anything and the
DM copes, but every outcome that matters runs through code I trust. That&amp;rsquo;s the
right shape for a tutorial, and, not by coincidence, the right shape for most AI
features you&amp;rsquo;d actually ship.&lt;/p&gt;
&lt;h2 id="what-the-goblin-taught-me"&gt;What the goblin taught me
&lt;/h2&gt;&lt;p&gt;The thing I keep coming back to is that the model never misbehaved. It resurrected
the goblin because I gave it the freedom to. It dithered because I gave it a button
to press. It refereed because I let it see the board. Every failure was a
permission I&amp;rsquo;d handed over without meaning to. The reliability didn&amp;rsquo;t come from a
cleverer prompt or a bigger model, it came from working out, one dead goblin at a
time, exactly how little the model needed to be trusted with.&lt;/p&gt;
&lt;p&gt;If you want the version where it all works first time, the
&lt;a class="link" href="https://phpboyscout.uk/building-a-cli-with-go-tool-base-part-4/" &gt;tutorial&lt;/a&gt;
has it, the
&lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;tool-calling&lt;/a&gt;
and the
&lt;a class="link" href="https://phpboyscout.uk/stop-regexing-the-llms-prose/" &gt;typed turns&lt;/a&gt;
wired up properly. This was the road there. The goblin, you&amp;rsquo;ll be glad to hear,
now stays down.&lt;/p&gt;</description></item><item><title>A signing key that never leaves KMS</title><link>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-that-never-leaves-kms/cover-a-signing-key-that-never-leaves-kms.png" alt="Featured image of post A signing key that never leaves KMS" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;The last post in this series&lt;/a&gt;
walked through how a tool &lt;em&gt;verifies&lt;/em&gt; a release signature the platform can&amp;rsquo;t forge.
That post had a loose end dangling off the back of it, and I knew it the whole time I
was writing. Because a signature has to be produced by a private key&amp;hellip; and a private
signing key is the single worst thing in this entire story to lose. Steal it, and you
sign malware that sails through every check I spent two posts building, signature and
all. So where does that key live? The answer I landed on is the one this whole post is
about: inside AWS KMS, and it never comes out.&lt;/p&gt;
&lt;h2 id="the-only-key-you-cant-steal"&gt;The only key you can&amp;rsquo;t steal
&lt;/h2&gt;&lt;p&gt;Think about where a signing key normally ends up. A file on a build server. A secret
in CI. A key on the release engineer&amp;rsquo;s laptop, &amp;ldquo;just for the release, I&amp;rsquo;ll delete it
after&amp;rdquo;. Every one of those is a copy, and every copy is one more thing somebody can
read, exfiltrate, or quietly clone while your back is turned. You can wrap them in
passphrases and vaults and rotation policies all you like, and you&amp;rsquo;re still standing
guard over a thing that &lt;em&gt;exists in a place you don&amp;rsquo;t fully control&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The way out is almost annoyingly simple to state: the only key nobody can steal is
the one that was never anywhere to be stolen from. So don&amp;rsquo;t hold the key at all. Let
something else hold it, somewhere it has no export path, and ask that thing to sign
&lt;em&gt;for&lt;/em&gt; you.&lt;/p&gt;
&lt;p&gt;That thing is AWS KMS. This is the infrastructure side of &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;the question I opened the
signing series with&lt;/a&gt;,
finally answered with real Terraform.&lt;/p&gt;
&lt;h2 id="a-key-thats-born-in-the-box-and-stays-there"&gt;A key that&amp;rsquo;s born in the box and stays there
&lt;/h2&gt;&lt;p&gt;The signing key is an asymmetric KMS key, and the
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L86-L100" target="_blank" rel="noopener"
 &gt;module that provisions it&lt;/a&gt;
is small enough to read in one sitting:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_kms_key&amp;#34; &amp;#34;this&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;description&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; key_usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;SIGN_VERIFY&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; customer_master_key_spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_spec&lt;/span&gt;&lt;span class="c1"&gt; # default RSA_4096
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # Asymmetric SIGN_VERIFY keys do not support KMS-managed rotation;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # rotation is handled by minting a new key (alias = `&amp;lt;name&amp;gt;-v2`) and
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # publishing the v2 public key alongside the v1 key (dual-sign window).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; enable_key_rotation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;json&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The private half of that key is generated inside KMS and there is no API that hands
it back to you. You don&amp;rsquo;t sign &lt;em&gt;with&lt;/em&gt; it the way you&amp;rsquo;d sign with a file. You call
&lt;code&gt;kms:Sign&lt;/code&gt;: the bytes you want signed go up, a signature comes back down, and the key
itself never moves. An attacker who completely owns my CI, my account, my laptop, can
ask KMS to sign things for as long as their access lasts&amp;hellip; but they can&amp;rsquo;t walk off
with the key and keep signing forever. The blast radius is &amp;ldquo;while I&amp;rsquo;m compromised&amp;rdquo;,
not &amp;ldquo;until I rotate a key I didn&amp;rsquo;t know had leaked three years ago&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Why RSA-4096 and not the Ed25519 I&amp;rsquo;d normally reach for? Because KMS asymmetric
signing doesn&amp;rsquo;t offer Ed25519, and OpenPGP&amp;rsquo;s packet format is tied to the algorithm
that signed it, so the choice of key spec ripples all the way out to the signature on
the wire. RSA-4096 is the strong option KMS does offer, so RSA-4096 is what the
workflow is built around. A constraint of the box shaped the cryptography, not the
other way round, and I&amp;rsquo;d rather say so than pretend I picked RSA on purpose.&lt;/p&gt;
&lt;h2 id="minting-an-openpgp-key-from-a-key-you-cant-hold"&gt;Minting an OpenPGP key from a key you can&amp;rsquo;t hold
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part I find genuinely neat. OpenPGP wants a private key to self-sign its
own public key when you generate it. And I don&amp;rsquo;t &lt;em&gt;have&lt;/em&gt; a private key in any form I
can hand to a library&amp;hellip; it&amp;rsquo;s sitting in KMS, behind a door with no handle on my side.
So how do you produce a valid OpenPGP public key at all?&lt;/p&gt;
&lt;p&gt;go-tool-base leans on a small Go interface, &lt;code&gt;crypto.Signer&lt;/code&gt;: anything that can return
its public key and sign a digest. A KMS-backed signer satisfies it by turning each
&lt;code&gt;Sign&lt;/code&gt; call into a &lt;code&gt;kms:Sign&lt;/code&gt; request. Then
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/openpgpkey/openpgpkey.go#L114-L126" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/openpgpkey&lt;/code&gt;&lt;/a&gt;
builds the OpenPGP entity around that signer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;openpgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;rsaPub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Public&lt;/span&gt;&lt;span class="p"&gt;().(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;rsa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewRSAPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creationTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rsaPub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// Construct the private-key packet directly (rather than&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// packet.NewSignerPrivateKey, which panics on opaque signers):&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// the crypto.Signer drives the actual signing, so a KMS-backed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// signer works here.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;privPkt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;PublicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;pubPkt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Look at that &lt;code&gt;PrivateKey&lt;/code&gt; packet. The field where OpenPGP expects the secret key
material holds the &lt;code&gt;crypto.Signer&lt;/code&gt; instead, which is to say, a remote handle to KMS.
When the entity self-signs its public key, that self-signature is computed by KMS.
&lt;code&gt;gtb keys mint&lt;/code&gt; runs exactly this and writes out an ASCII-armored OpenPGP public key,
and at no point did a single byte of private key material exist on the machine that
minted it. The OpenPGP &amp;ldquo;private key&amp;rdquo; is a phone line to a vault, not a key.&lt;/p&gt;
&lt;p&gt;That public key is what gets published off-platform over WKD and baked into the
binary, the two trust anchors that post cross-checks.&lt;/p&gt;
&lt;h2 id="access-without-a-human-and-without-a-standing-key"&gt;Access without a human and without a standing key
&lt;/h2&gt;&lt;p&gt;A key that never leaves KMS is only as good as the rules about who may call
&lt;code&gt;kms:Sign&lt;/code&gt;. The
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-signing-kms/-/blob/v0.1.0/main.tf#L49-L82" target="_blank" rel="noopener"
 &gt;signer role&lt;/a&gt;
is deliberately narrow: it can call &lt;code&gt;kms:Sign&lt;/code&gt; and &lt;code&gt;kms:GetPublicKey&lt;/code&gt; on this one
key and nothing else, and it is assumable only over OIDC from specific CI subjects,
the same &lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;keyless federation&lt;/a&gt;
the rest of the estate runs on. No human holds it. No long-lived access key sits in
a CI variable waiting to leak. A release job federates in for its few minutes,
signs, and the credentials evaporate with the runner.&lt;/p&gt;
&lt;p&gt;So the chain of &amp;ldquo;who can sign a release&amp;rdquo; has no standing secret in it anywhere. Not a
key file, not an access key, not a console user. Just a short-lived token, scoped to
two API calls, on a key that can&amp;rsquo;t be exported.&lt;/p&gt;
&lt;h2 id="the-real-cost-rotation-is-manual"&gt;The real cost: rotation is manual
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t free, and the bit it taxes you on is rotation. KMS won&amp;rsquo;t auto-rotate an
asymmetric &lt;code&gt;SIGN_VERIFY&lt;/code&gt; key, which is why the module sets &lt;code&gt;enable_key_rotation = false&lt;/code&gt; rather than leaving a default on. Rotating means minting a &lt;em&gt;new&lt;/em&gt; key (a &lt;code&gt;-v2&lt;/code&gt;
alias), publishing its public key alongside the old one, and running a dual-sign
window long enough that clients have picked up the new anchor before you retire the
old. It&amp;rsquo;s manual, it&amp;rsquo;s a runbook, and pretending otherwise would be the kind of thing
this series exists to argue against. The trade I made was: a key with no exfiltration
path, in exchange for rotation I have to do by hand. For a release-signing key, that&amp;rsquo;s
the right side of the trade.&lt;/p&gt;
&lt;h2 id="why-this-is-a-command-and-not-a-script-i-hid"&gt;Why this is a command and not a script I hid
&lt;/h2&gt;&lt;p&gt;The origin of all this is a good deal less tidy than the result. I was working through
the key-generation runbook, creating the offline rotation key with a &lt;code&gt;gpg&lt;/code&gt; command I&amp;rsquo;d
copied straight off my own page&amp;hellip; and it just hung. No error, no prompt, just a cursor
blinking while gpg waited on something it never bothered to mention.&lt;/p&gt;
&lt;p&gt;My first instinct was the lazy one: drop the minting script into a &lt;code&gt;scripts&lt;/code&gt; folder in
my infra repo and never speak of it again. Then it nagged. That repo&amp;rsquo;s private, so the
recipe would live somewhere nobody else could ever reach, and I&amp;rsquo;d already half-promised
myself a tutorial walking people through this exact setup. So it shouldn&amp;rsquo;t sit in infra
at all. It should be a &lt;code&gt;gtb&lt;/code&gt; command, with a pluggable backend so anyone can swap my KMS
for whatever provider they happen to run.&lt;/p&gt;
&lt;p&gt;The deeper objection is the one that actually shaped it, though. I didn&amp;rsquo;t want to be
shelling out to &lt;code&gt;gpg&lt;/code&gt; by hand in the first place. gtb is a tool I hand to other people,
and every time it drops to the shell for some gpg incantation, that&amp;rsquo;s an environment
I&amp;rsquo;m asking the next person to reproduce, a dependency to install, a fiddly step to get
subtly wrong, all before they can sign a single thing. The aim was to keep as much of
this &lt;em&gt;inside the box&lt;/em&gt; as I could: mint the key, build the WKD tree, produce the
signature, all in pure Go, with no &lt;code&gt;gpg&lt;/code&gt; on the path and no &lt;code&gt;gpg-wks-client&lt;/code&gt; either.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;gtb keys mint&lt;/code&gt; pulls the public half out of your KMS key and frames it as OpenPGP,
the trick from earlier; &lt;code&gt;gtb keys wkd&lt;/code&gt; builds the tree ready to upload; and &lt;code&gt;gtb sign&lt;/code&gt;
produces the detached signature through that same remote round-trip. What comes out is
an entirely ordinary OpenPGP signature &lt;code&gt;gpg --verify&lt;/code&gt; is happy with, so you&amp;rsquo;re not
locked into anything of mine. And none of it is just for me: build your tool on
go-tool-base and the same handful of commands stands you up with this exact model,
pointed at your own KMS. No cloud KMS to hand? There&amp;rsquo;s a &lt;code&gt;local&lt;/code&gt; backend, a plain key
on disk, to wire the whole thing together on your laptop first. These are commands for
you, the person shipping the tool. Your users never run &lt;code&gt;mytool keys mint&lt;/code&gt;&amp;hellip; they just
get updates that quietly check themselves, which was the whole idea two posts ago.&lt;/p&gt;
&lt;p&gt;That setup deserves a walkthrough of its own, and it&amp;rsquo;ll get one. For now, the
ergonomics were the point, not a nicety bolted on afterwards. The safest setup in the
world is no use to anyone if it takes a PhD to stand up.&lt;/p&gt;
&lt;h2 id="where-this-leaves-the-whole-story"&gt;Where this leaves the whole story
&lt;/h2&gt;&lt;p&gt;Step back and the full loop is finally closed. The private key is born in KMS and
never leaves it. Its public key is minted &lt;em&gt;from&lt;/em&gt; it, with KMS computing its own
self-signature. That public key is published off-platform and embedded in the binary.
Releases are signed by KMS, reached only through short-lived OIDC federation. And the
client verifies against the embedded and WKD keys &lt;a class="link" href="https://phpboyscout.uk/a-signature-the-platform-cant-forge/" &gt;cross-checked against each
other&lt;/a&gt;. At no
single point in that chain is there a thing an attacker can grab that lets them forge
a release, and the most dangerous thing of all, the private key, has no theft path
because it has no export path.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the thread running through the whole signing series, from &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;the very first
checksum&lt;/a&gt; to here: the
strongest control isn&amp;rsquo;t a better lock on the key. It&amp;rsquo;s arranging things so the key
was never somewhere you could lose it. &lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;Nobody is coming to clean your supply
chain&lt;/a&gt;,
so the least you can do is leave it nothing worth stealing.&lt;/p&gt;</description></item><item><title>A signature the platform can't forge</title><link>https://phpboyscout.uk/a-signature-the-platform-cant-forge/</link><pubDate>Tue, 09 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signature-the-platform-cant-forge/</guid><description>&lt;img src="https://phpboyscout.uk/a-signature-the-platform-cant-forge/cover-a-signature-the-platform-cant-forge.png" alt="Featured image of post A signature the platform can't forge" /&gt;&lt;p&gt;A self-updating tool has a chicken-and-egg problem baked into it. The thing doing the
updating is the thing being updated, so when it reaches out and pulls down a newer
version of itself, it&amp;rsquo;s the one that has to decide whether to trust what just landed.
No human in the loop, nobody to ask. I&amp;rsquo;ve been closing that gap in go-tool-base&amp;rsquo;s
self-updater in two phases. The
&lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;first&lt;/a&gt; gave it a
checksum: download the new binary, hash it, compare it against the release&amp;rsquo;s
&lt;code&gt;checksums.txt&lt;/code&gt;. That catches the accidents, the truncated download, the flipped bit
on a dodgy mirror. And I said at the time, plainly, that it does nothing about a
determined attacker who owns the release platform&amp;hellip; the checksums file sits right
next to the binary, so whoever can swap one can swap both. I left that as an IOU.
This second phase is me paying it.&lt;/p&gt;
&lt;h2 id="the-thing-a-checksum-cant-do"&gt;The thing a checksum can&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;A checksum is a promise that the bytes you got match the manifest. It says nothing
about &lt;em&gt;who wrote the manifest&lt;/em&gt;. So if GitLab, or my account, or a leaked CI token
gets compromised, the attacker rewrites the binary and the &lt;code&gt;checksums.txt&lt;/code&gt; in the
same breath, and the hash matches perfectly, because they&amp;rsquo;re the one who computed it.
It&amp;rsquo;s the same wall I keep walking into whenever I think about
&lt;a class="link" href="https://phpboyscout.uk/nobody-is-coming-to-clean-your-supply-chain/" &gt;supply-chain trust&lt;/a&gt;:
a checksum is only ever as good as whatever&amp;rsquo;s standing behind it, and the thing
standing behind a checksum is the very platform that just handed you the file. Same
hands, both times.&lt;/p&gt;
&lt;p&gt;To get past that, you need a signature whose root of trust lives somewhere the
platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;h2 id="the-crypto-is-the-easy-part"&gt;The crypto is the easy part
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit that caught me slightly off guard while I was building this: the
cryptography is the easy part. Verifying a detached OpenPGP signature is a library
call, and go-tool-base&amp;rsquo;s &lt;code&gt;TrustSet&lt;/code&gt; wraps it up in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L237-L257" target="_blank" rel="noopener"
 &gt;one method&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;TrustSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;VerifyManifestSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;openpgp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckArmoredDetachedSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;signer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrSignatureInvalid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;no signer in trust set matched&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Hand it the manifest, the detached signature, and a set of trusted public keys (the
&lt;code&gt;entities&lt;/code&gt;), and it tells you whether any one of them signed it. That&amp;rsquo;s the whole of
the cryptography, and it&amp;rsquo;s genuinely not where the hard work lives.&lt;/p&gt;
&lt;p&gt;The hard work is that set of trusted public keys. Where do they come from? Because if
the answer is &amp;ldquo;we ship them right next to the binary&amp;rdquo;, well&amp;hellip; you&amp;rsquo;re straight back
to the checksum problem. Whoever can swap the binary can swap the key too, sign with
their own, and the check waves it through none the wiser.&lt;/p&gt;
&lt;h2 id="pulling-the-two-questions-apart"&gt;Pulling the two questions apart
&lt;/h2&gt;&lt;p&gt;So the design splits along exactly that seam. The verification half is fixed, and
deliberately boring (the method above). The trust anchor, the actual keys, comes from
a swappable
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L259-L274" target="_blank" rel="noopener"
 &gt;&lt;code&gt;KeyResolver&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// The interface separates &amp;#34;where the trust anchor comes from&amp;#34; from &amp;#34;how a&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// signature is verified against it&amp;#34;, so SelfUpdater can be wired with&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// whichever resolver chain a tool needs without changing verification logic.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;KeyResolver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nf"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nf"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;TrustSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That little seam is really the whole game. Everything interesting about standing up
to a compromised platform comes down to which resolver you hand the updater, and the
verification code never has to know the difference.&lt;/p&gt;
&lt;h2 id="three-answers-to-where-does-the-key-live"&gt;Three answers to &amp;ldquo;where does the key live&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The first option is to embed it. Bake the public key straight into the binary at
build time (&lt;code&gt;NewEmbeddedResolver&lt;/code&gt;), so it rides along inside a release you already
trusted enough to run. Tidy and self-contained. The catch is that a &lt;em&gt;future&lt;/em&gt;
malicious release could embed a different key, so on its own, embedding really just
trusts whoever cut the most recent binary.&lt;/p&gt;
&lt;p&gt;The second is WKD, the Web Key Directory. Fetch the key over HTTPS from a well-known
path on a domain you control (&lt;code&gt;NewWKDResolver&lt;/code&gt;), nothing to do with where the release
itself is hosted. Now the key isn&amp;rsquo;t in the binary at all, so poisoning a release
doesn&amp;rsquo;t touch it. You haven&amp;rsquo;t made the problem disappear, mind&amp;hellip; you&amp;rsquo;ve moved the
trust onto your domain&amp;rsquo;s host and its DNS. A different blast radius, but a blast
radius all the same.&lt;/p&gt;
&lt;p&gt;The third option is to do both, and make them agree. Run embedded &lt;em&gt;and&lt;/em&gt; WKD, and
insist they &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing_composite.go#L61-L82" target="_blank" rel="noopener"
 &gt;agree&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;CompositeResolver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;TrustSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ... run each child resolver concurrently ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;checkAgreement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;successes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ErrKeyResolverMismatch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;successes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Think of it as the two-key rule on a safe deposit box, or two witnesses who&amp;rsquo;ve never
met telling you the same story. One source on its own you might quietly doubt. But if
the key baked into the binary and the key sitting on my domain hand back the same
fingerprint, that agreement is worth a great deal more than either of them alone. And
if they ever come back different, that&amp;rsquo;s not a maybe, that&amp;rsquo;s an alarm:
&lt;code&gt;ErrKeyResolverMismatch&lt;/code&gt;. Poison one source and the mismatch is the thing that gives
the game away.&lt;/p&gt;
&lt;p&gt;That composite is the real answer, and it&amp;rsquo;s why the interface exists at all. There&amp;rsquo;s
nothing a single attacker can get their hands on that holds the whole thing up by
itself. The key is baked into a release you trusted, &lt;em&gt;and&lt;/em&gt; fetched from a domain well
off the release platform, &lt;em&gt;and&lt;/em&gt; the two have to match before a single byte of the
update is allowed through.&lt;/p&gt;
&lt;h2 id="the-separation-is-the-whole-point"&gt;The separation is the whole point
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to nod along at &amp;ldquo;two sources&amp;rdquo; and miss the part that actually does the work.
The agreement between the embedded key and the WKD key is only worth something if an
attacker can&amp;rsquo;t reach both of them from the same place. If the key I bake into the
binary and the key I serve over WKD both came out of the same release pipeline,
whoever owns that pipeline swaps the pair of them, the fingerprints still match, and
the cross-check happily waves the forgery through. Same hands, both times. Again.&lt;/p&gt;
&lt;p&gt;So they don&amp;rsquo;t share a pipeline, and that&amp;rsquo;s the entire design, not an accident of how
things ended up. The binary, and the key embedded in it, are built and signed in
GitLab CI, which federates into AWS KMS to do the signing itself. The WKD key lives
somewhere else completely: a Cloudflare Pages site serving &lt;code&gt;openpgpkey.phpboyscout.uk&lt;/code&gt;,
deployed by hand at rotation time with the Wrangler CLI and a token allowed to do
nothing but edit that one Pages project. No Git integration, no webhook, nothing that
lets a push to the repo or a run of the release pipeline so much as touch it. The
Cloudflare account is even administered under a different email and a different second
factor from the GitLab and AWS ones, so the three anchors really are independent
rather than just feeling that way.&lt;/p&gt;
&lt;p&gt;Which is what makes them fail independently, and that independence is the only thing
that makes the agreement worth checking. To forge a release that survives the
cross-check, an attacker doesn&amp;rsquo;t have to beat one system, they have to beat two
unrelated ones, on different platforms, behind different credentials, in the same
window, without either of them noticing.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a quieter benefit in the cadence, too. Releases go out constantly and
automatically; the WKD key changes rarely, and only ever by hand. So the busy,
automated path, the one an attacker is most likely to prise open, is exactly the one
with no power to rewrite the key everyone checks against.&lt;/p&gt;
&lt;h2 id="requiring-it-without-breaking-everyone"&gt;Requiring it, without breaking everyone
&lt;/h2&gt;&lt;p&gt;Now, a check nobody ever switches on is just theatre. But switch it on before the
keys are actually out there in people&amp;rsquo;s installs, and you&amp;rsquo;ve handed everyone a
self-inflicted outage instead. So the default is deliberately timid. The framework
ships
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/v0.12.2/pkg/setup/signing.go#L47-L57" target="_blank" rel="noopener"
 &gt;&lt;code&gt;DefaultRequireSignature = false&lt;/code&gt;&lt;/a&gt;:
a tool built on go-tool-base doesn&amp;rsquo;t suddenly start rejecting its own updates the day
its author bumps the framework version.&lt;/p&gt;
&lt;p&gt;The tool author flips it to &lt;code&gt;true&lt;/code&gt; in &lt;code&gt;main()&lt;/code&gt;, but only &lt;em&gt;after&lt;/em&gt; they&amp;rsquo;ve shipped a
release that embeds the key, so every install out there already holds the trust
anchor before the first release that insists on one. Ship the key, then turn the
lock: the same leave-yourself-a-way-back discipline as any migration you&amp;rsquo;d like to
still have a job after. And the end user still gets an override
(&lt;code&gt;update.require_signature&lt;/code&gt;, or an env var) for the day it all goes sideways and they
need out.&lt;/p&gt;
&lt;h2 id="what-it-actually-buys"&gt;What it actually buys
&lt;/h2&gt;&lt;p&gt;The first phase stopped accidents. This one stops the platform. And not because the
cryptography is clever, OpenPGP checks the signature in a single call, but because the
trust anchor is arranged so that nothing the attacker can actually reach holds the
whole thing up on its own. A signature only ever proves the sender, never the
contents. All of this is really about making &amp;ldquo;the sender&amp;rdquo; something a compromised
release host can&amp;rsquo;t quietly fake its way into being.&lt;/p&gt;
&lt;p&gt;Which leaves one last thread dangling. The &lt;em&gt;verifying&lt;/em&gt; key gets fetched from
somewhere, fine&amp;hellip; but the &lt;em&gt;signing&lt;/em&gt; key, the private half that actually produces
these signatures, has to live somewhere the platform can&amp;rsquo;t reach either, or none of
the rest holds up. That&amp;rsquo;s the capstone, and where this series ends: where that key
lives, and why it never leaves the box it&amp;rsquo;s born in.&lt;/p&gt;</description></item><item><title>Three traps release-plz sets for a Rust workspace</title><link>https://phpboyscout.uk/three-traps-release-plz-workspace/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/three-traps-release-plz-workspace/</guid><description>&lt;img src="https://phpboyscout.uk/three-traps-release-plz-workspace/cover-three-traps-release-plz-workspace.png" alt="Featured image of post Three traps release-plz sets for a Rust workspace" /&gt;&lt;p&gt;I wrote up the two days I lost releasing a seventeen-crate workspace to crates.io
as &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;a war story&lt;/a&gt;, wrong
turns and all. This is the other half: the field guide, so you don&amp;rsquo;t have to lose
the same two days.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt; is a genuinely good tool, and none of what
follows is a bug. It&amp;rsquo;s three behaviours that are entirely within its design and
will still ambush you the moment you point it at a Cargo &lt;em&gt;workspace&lt;/em&gt; rather than a
single crate. Mildest first, because the third is the one that actually ate my
release.&lt;/p&gt;
&lt;h2 id="first-what-release-plz-is-doing"&gt;First, what release-plz is doing
&lt;/h2&gt;&lt;p&gt;In one line: it&amp;rsquo;s release-please for cargo. It keeps a Release MR open, bumps your
versions and per-crate changelogs from your Conventional Commits, and when that MR
merges it publishes every crate to crates.io and tags the release. On a workspace
where N crates all share one version, &amp;ldquo;the release&amp;rdquo; is N publishes and N tag
operations. Hold on to that N. It&amp;rsquo;s hiding behind all three traps.&lt;/p&gt;
&lt;h2 id="trap-1-the-default-tag-template-is-built-for-one-crate-not-a-workspace"&gt;Trap 1: the default tag template is built for one crate, not a workspace
&lt;/h2&gt;&lt;p&gt;You will reach for one tag per version, and for me it was more than tidiness. I
wanted to ship the whole framework as a single release: one &lt;code&gt;v0.5.1&lt;/code&gt; covering all
seventeen crates, because that was the compatibility promise I wanted to make.
Use the crates that share a version and they&amp;rsquo;re guaranteed to work together. A
single tag felt like the natural way to say &amp;ldquo;this is one coherent release of the
whole thing&amp;rdquo; (and it didn&amp;rsquo;t hurt that the repo already had a &lt;code&gt;v0.5.0&lt;/code&gt; tag from
before release-plz, so one unified tag also looked like continuity). So you either
set this, or, worse, you leave &lt;code&gt;git_tag_name&lt;/code&gt; unset assuming the default does
something workspace-aware:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the catch. release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; &lt;code&gt;v{{ version }}&lt;/code&gt;, and
release-plz tags &lt;strong&gt;per crate&lt;/strong&gt;. So the first crate publishes and creates the tag
&lt;code&gt;v0.5.1&lt;/code&gt;. The second crate publishes and tries to create &lt;code&gt;v0.5.1&lt;/code&gt; again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time you read that error, the first crate (and on a retry, the next, and
the next) is already live on crates.io, and crates.io publishes are forever.
Leaving the line out doesn&amp;rsquo;t save you, because the default is the same
single-crate-shaped template. This is the trap I walked straight into on the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;release commit&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="trap-2-one-release-for-the-whole-workspace-isnt-a-setting-its-a-category-error"&gt;Trap 2: &amp;ldquo;one release for the whole workspace&amp;rdquo; isn&amp;rsquo;t a setting, it&amp;rsquo;s a category error
&lt;/h2&gt;&lt;p&gt;The natural next thought is &amp;ldquo;fine, I&amp;rsquo;ll keep one tag but configure release-plz to
roll the crates into a single release.&amp;rdquo; There&amp;rsquo;s no knob for that, and chasing one
is a waste of an afternoon. release-plz&amp;rsquo;s model is per-crate all the way down:
per-crate tags, per-crate GitLab/GitHub releases, per-crate changelogs. &amp;ldquo;One
unified release for the whole workspace&amp;rdquo; isn&amp;rsquo;t an option it withholds, it&amp;rsquo;s a
shape it doesn&amp;rsquo;t have.&lt;/p&gt;
&lt;p&gt;So you stop fighting it and
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;set the per-crate templates explicitly&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now each crate gets its own tag (&lt;code&gt;rtb-assets-v0.5.1&lt;/code&gt;, &lt;code&gt;rtb-config-v0.5.1&lt;/code&gt;, and so
on) and its own release. It&amp;rsquo;s more objects per version than you wanted, but it&amp;rsquo;s
the grain the tool works in, and once you accept that the collisions stop.&lt;/p&gt;
&lt;p&gt;This is where I had to pull apart two things I&amp;rsquo;d quietly merged in my head: the
version and the tag. The compatibility promise I cared about, that crates sharing
a version work together, is carried by the &lt;em&gt;version&lt;/em&gt;, and release-plz keeps every
crate on the one workspace version no matter how it tags them. The tag is just a
label pointing at a commit. I&amp;rsquo;d wanted a single tag to mean &amp;ldquo;one coherent
framework release&amp;rdquo;, but the coherence was always in the shared version number, not
in the tag. Once that landed, seventeen tags stopped feeling like seventeen
releases of seventeen different things and started looking like what they are:
seventeen labels on one versioned release. The version is not the tag. If you still want
one human-facing narrative for the whole thing, keep a hand-written root
&lt;code&gt;CHANGELOG.md&lt;/code&gt; alongside the generated per-crate ones, rather than trying to make
release-plz aggregate.&lt;/p&gt;
&lt;h2 id="trap-3-a-release-reads-its-config-from-the-release-commit-not-head"&gt;Trap 3: a release reads its config from the release commit, not HEAD
&lt;/h2&gt;&lt;p&gt;This is the small one, and the one that cost me the most, because it makes the fix
for Trap 1 look like it isn&amp;rsquo;t working.&lt;/p&gt;
&lt;p&gt;When release-plz runs a &lt;code&gt;release&lt;/code&gt;, it does not read &lt;code&gt;release-plz.toml&lt;/code&gt; from your
working tree. It reads it from the &lt;strong&gt;release commit&lt;/strong&gt;, the commit that first
introduced the version it&amp;rsquo;s releasing. So picture the obvious recovery: you hit
the tag collision, you realise your template is wrong, you fix it in a follow-up
commit and push to main. Your fix is real. It&amp;rsquo;s committed. It&amp;rsquo;s on the default
branch. And it is completely ignored, because the version hasn&amp;rsquo;t changed, so the
release commit release-plz reads from is still the old one with the old template.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t take this on faith. With the corrected per-crate template sitting on
&lt;code&gt;HEAD&lt;/code&gt;, the CI release job still tried to create the unified tag, pinned to the
&lt;em&gt;old&lt;/em&gt; commit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;ref&lt;/code&gt; is the release commit, not the HEAD that held my fix. And the cruel
part: &lt;code&gt;release-plz release --dry-run&lt;/code&gt; on your laptop reads your &lt;em&gt;working-directory&lt;/em&gt;
config, so it renders the shiny new per-crate tags and tells you you&amp;rsquo;re sorted. CI
runs the real thing against the release commit and does something else entirely.
Same config file, two different answers depending on who&amp;rsquo;s asking, which is why
the war story has &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;the title it does&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The operational rule that falls out of this: &lt;strong&gt;any release-plz config change that
affects how a release behaves has to ride along with a version bump, or it does
not apply.&lt;/strong&gt; A &amp;ldquo;fix-up&amp;rdquo; commit on its own is a no-op.&lt;/p&gt;
&lt;h2 id="if-you-set-one-thing"&gt;If you set one thing
&lt;/h2&gt;&lt;p&gt;If you run release-plz on a multi-crate workspace and you change a single line
from the defaults, make it the tag template:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And set it &lt;em&gt;before&lt;/em&gt; your first release, not during it, so it&amp;rsquo;s already in the
commit that introduces the version, because that&amp;rsquo;s the only commit a release will
ever read it from. Everything else here follows from two facts: the grain is
per-crate, and CI reads history while your laptop reads your working tree. Trust
the history.&lt;/p&gt;
&lt;p&gt;None of this is release-plz misbehaving. Every bit of it is documented and
deliberate. It just isn&amp;rsquo;t where you&amp;rsquo;ll think to look until it has published six
crates you can&amp;rsquo;t take back, which is roughly how I came to know it so well.&lt;/p&gt;</description></item><item><title>Telemetry that asks, and telemetry that doesn't</title><link>https://phpboyscout.uk/telemetry-that-asks-and-telemetry-that-doesnt/</link><pubDate>Thu, 04 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/telemetry-that-asks-and-telemetry-that-doesnt/</guid><description>&lt;img src="https://phpboyscout.uk/telemetry-that-asks-and-telemetry-that-doesnt/cover-telemetry-that-asks-and-telemetry-that-doesnt.png" alt="Featured image of post Telemetry that asks, and telemetry that doesn't" /&gt;&lt;p&gt;go-tool-base has had a thing called telemetry for a long while now. It&amp;rsquo;s the
opt-in kind: the &lt;a class="link" href="https://phpboyscout.uk/telemetry-that-asks-first/" &gt;product analytics&lt;/a&gt;
that asks a user&amp;rsquo;s permission before it phones a single byte home, sits there as
a no-op until they say yes, and can be wiped on request. The whole package is
built around consent.&lt;/p&gt;
&lt;p&gt;Then the &lt;a class="link" href="https://phpboyscout.uk/building-a-web-service-with-go-tool-base-part-6/" &gt;web-service series&lt;/a&gt;
went and needed telemetry too. Not that telemetry. The other one, the one the
rest of the industry means when it says the word: traces, metrics and logs of a
running service. And the awkward thing about those two is that they share a name,
they want to share a package, and they pull in exactly opposite directions on the
one question that matters most.&lt;/p&gt;
&lt;p&gt;This is the story of how 0.7.x grew a second telemetry without breaking the
first, and where the line between them ended up getting drawn.&lt;/p&gt;
&lt;h2 id="why-bother-putting-it-in-the-framework-at-all"&gt;Why bother putting it in the framework at all
&lt;/h2&gt;&lt;p&gt;The starting point is that I could have left observability out. A reader could
wire up OpenTelemetry in their own service and go about their day. But the six
parts of the web-service series spent a lot of effort making the transports
first-class: a gRPC server, an HTTP server, a gateway, TLS across all of them,
each one a &lt;code&gt;Register&lt;/code&gt; call against the controller. Turning a CLI into a real
long-running service and then shrugging &amp;ldquo;observability is your problem&amp;rdquo; would
have left a hole exactly where it hurts.&lt;/p&gt;
&lt;p&gt;Because a service you can&amp;rsquo;t see into is a liability the moment it leaves your
laptop. The series ended with a macguffin service that was typed, fast and served
over TLS, and was also a black box: when it got slow, you had nowhere to look.
Metrics and traces are how you get the lights on, and they deserved the same
first-class treatment as the things they observe.&lt;/p&gt;
&lt;p&gt;The other half of the reason is that the framework already had a foot in this
world. The analytics package&amp;rsquo;s preferred backend speaks OTLP, the OpenTelemetry
wire protocol. So OpenTelemetry was already in the building. Doing observability
any other way would have meant two standards where one would do.&lt;/p&gt;
&lt;h2 id="the-catch-two-telemetries-opposite-instincts"&gt;The catch: two telemetries, opposite instincts
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where it gets interesting, and it&amp;rsquo;s the part worth slowing down on.&lt;/p&gt;
&lt;p&gt;The analytics telemetry is about a user. It collects usage data, hashed machine
id, which command ran, exit code, and the entire design assumes you have to ask
first. It is off by default. The collector you get when it&amp;rsquo;s disabled is a no-op,
so nothing is recorded until the user opts in, and there&amp;rsquo;s a deletion path for
when they change their mind. That&amp;rsquo;s not an add-on, that&amp;rsquo;s by design.&lt;/p&gt;
&lt;p&gt;The observability telemetry is about a service. It emits operational data, how
long a request took, which span was slow, how many errored, to a collector the
operator runs. And there is no user in the loop to ask. The operator deploys the
service, points it at their collector, and that act is itself the consent. Asking
would be nonsensical: whose permission, for data about their own service, on
their own infrastructure?&lt;/p&gt;
&lt;p&gt;So you have two things called telemetry, wanting to live in one package, with the
opposite default on consent. One is off until someone says yes; the other is on
the moment it&amp;rsquo;s configured. Get that wiring wrong and you fail in one of two ugly
ways. Gate the operational telemetry behind the user&amp;rsquo;s analytics opt-in, and a
service&amp;rsquo;s tracing silently does nothing because nobody ticked a box meant for
something else. Or loosen the analytics gate to make observability flow, and you
start leaking usage data the user never agreed to share. Neither is acceptable,
and &amp;ldquo;just use two packages&amp;rdquo; throws away everything the two genuinely have in
common.&lt;/p&gt;
&lt;h2 id="what-they-actually-share"&gt;What they actually share
&lt;/h2&gt;&lt;p&gt;Quite a lot, as it turns out, and all of it below the consent line.&lt;/p&gt;
&lt;p&gt;Both ship their data over OTLP to a collector. Both need to describe who is
emitting, the service name and version, the resource in OpenTelemetry&amp;rsquo;s terms.
Both parse an endpoint, attach headers, decide whether the connection is
plaintext. None of that has the faintest thing to do with consent. It&amp;rsquo;s just the
plumbing of getting bytes to a collector, and the analytics backend already had
all of it, written inline.&lt;/p&gt;
&lt;p&gt;So the shape of the solution fell out of the problem. Lift the shared plumbing
into one place, let both telemetries stand on it, and keep the consent decision
firmly out of that shared layer. The structure under &lt;code&gt;pkg/telemetry&lt;/code&gt; ended up
like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;pkg/telemetry/
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; telemetry.go the analytics Collector (consent-gated)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; backend_otel.go its OTLP backend
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; posthog/ datadog/ vendor analytics backends
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; otelcore/ shared: OTLP endpoint, resource, telemetry.* config
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; tracing/ observability signal
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; metrics/ observability signal
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; logs/ observability signal
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; observability.go Setup: builds the enabled signals (implied consent)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The new &lt;code&gt;otelcore&lt;/code&gt; is the keystone. It holds the three things both sides need and
nothing they don&amp;rsquo;t:
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/otelcore/endpoint.go#L22" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ParseEndpoint&lt;/code&gt;&lt;/a&gt;
for the OTLP URL,
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/otelcore/resource.go#L11" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Resource&lt;/code&gt;&lt;/a&gt;
for the service identity, and
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/otelcore/config.go#L33" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Resolve&lt;/code&gt;&lt;/a&gt;
for reading the shared &lt;code&gt;telemetry.*&lt;/code&gt; config (a base endpoint, plus per-signal
overrides, in the same cascade as the TLS config). It imports no signal exporter
and knows nothing about traces, metrics, logs or analytics. It is deliberately
dumb plumbing.&lt;/p&gt;
&lt;h2 id="the-refactor-making-the-old-telemetry-stand-on-the-new-core"&gt;The refactor: making the old telemetry stand on the new core
&lt;/h2&gt;&lt;p&gt;This next part is where the old telemetry and the new one become a single thing.
The analytics OTLP backend was the first user of OTLP in the framework, and it had
grown its own copy of all that
plumbing: a function that parsed the endpoint URL, split out the host and path,
worked out the insecure flag, and built the resource from a service name. Exactly
the code the three new signals were about to need.&lt;/p&gt;
&lt;p&gt;So rather than write it a second time and let the two drift, the analytics
backend was refactored onto &lt;code&gt;otelcore&lt;/code&gt;. Its exporter builder,
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/backend_otel.go#L134" target="_blank" rel="noopener"
 &gt;&lt;code&gt;buildOTelExporterOpts&lt;/code&gt;&lt;/a&gt;,
now calls &lt;code&gt;otelcore.ParseEndpoint&lt;/code&gt;, the same function &lt;code&gt;tracing&lt;/code&gt;, &lt;code&gt;metrics&lt;/code&gt; and
&lt;code&gt;logs&lt;/code&gt; call, and the resource comes from &lt;code&gt;otelcore.Resource&lt;/code&gt;, the same one they
use. One implementation of &amp;ldquo;talk OTLP to a collector&amp;rdquo;, four callers: the
analytics backend and the three observability signals. Change how the framework
forms an OTLP endpoint, and every signal moves together.&lt;/p&gt;
&lt;p&gt;The reassuring part was that the analytics tests didn&amp;rsquo;t budge. The refactor moved
code without changing behaviour, and the consent machinery, the opt-in, the
no-op-when-disabled, the deletion path, never came near &lt;code&gt;otelcore&lt;/code&gt;. Which is
exactly the point.&lt;/p&gt;
&lt;h2 id="where-the-line-is"&gt;Where the line is
&lt;/h2&gt;&lt;p&gt;Because the shared core is the easy half. The half that earns its keep is the bit
that isn&amp;rsquo;t shared, and it&amp;rsquo;s a single, deliberate line.&lt;/p&gt;
&lt;p&gt;The analytics collector keeps its gate. The constructor,
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/telemetry.go#L84" target="_blank" rel="noopener"
 &gt;&lt;code&gt;NewCollector&lt;/code&gt;&lt;/a&gt;,
still returns a no-op the moment telemetry is disabled, so a user who hasn&amp;rsquo;t opted
in gets a collector that silently discards everything. Informed consent, untouched.&lt;/p&gt;
&lt;p&gt;Observability gets a different door entirely.
&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/f627270/pkg/telemetry/observability.go#L47" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Setup&lt;/code&gt;&lt;/a&gt;
builds whichever signals the operator has switched on, and it is gated only by
&lt;code&gt;telemetry.tracing.enabled&lt;/code&gt; and its siblings, which the operator sets. It never
consults the analytics opt-in. Turning on tracing doesn&amp;rsquo;t turn on analytics;
disabling analytics doesn&amp;rsquo;t silence tracing. The two enable flags live under the
same &lt;code&gt;telemetry.*&lt;/code&gt; config root, sit next to each other, and never read each
other.&lt;/p&gt;
&lt;p&gt;So that&amp;rsquo;s the whole architecture in a sentence: one package, one OTLP export core,
two consent models that share everything except the answer to &amp;ldquo;do we need to
ask&amp;rdquo;. The principle underneath, the one that decided every one of these calls, is
that the &lt;em&gt;kind of data&lt;/em&gt; sets the consent model. Usage data about a person needs
informed consent. Operational data about a service runs on implied consent. The
CLI and the web service are just where each kind tends to live.&lt;/p&gt;
&lt;h2 id="where-this-leaves-the-framework"&gt;Where this leaves the framework
&lt;/h2&gt;&lt;p&gt;0.7.x came out the other side with both telemetries: the one that asks first,
exactly as it was, and a new one that doesn&amp;rsquo;t, because it has nobody to ask. They
share an export core, a config root and a name, and they part company on the only
thing they were ever going to disagree about.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve been careful here to describe how the two consent models are kept apart, not
to argue why they have to be. That argument, that &amp;ldquo;the kind of data decides
the consent model&amp;rdquo; is a line worth holding rather than a convenient bit of
engineering, is a piece of its own, and it&amp;rsquo;s the one I&amp;rsquo;m writing next.&lt;/p&gt;</description></item><item><title>Same config, two answers</title><link>https://phpboyscout.uk/same-config-two-answers/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/same-config-two-answers/</guid><description>&lt;img src="https://phpboyscout.uk/same-config-two-answers/cover-same-config-two-answers.png" alt="Featured image of post Same config, two answers" /&gt;&lt;p&gt;Let me confess a small heresy first, because it&amp;rsquo;s the reason any of this
happened. After a career spent as a branching man, gitflow, gitlabflow, a
tidy &lt;code&gt;develop&lt;/code&gt; branch and a careful dance of merges, I&amp;rsquo;ve come round to
trunk-based development. I resisted it for years. It felt like working without
a net.&lt;/p&gt;
&lt;p&gt;What changed my mind was working solo with an AI pair. The branch ceremony that
earns its keep on a team of eight is just drag when it&amp;rsquo;s me and a model at
two in the morning. So I&amp;rsquo;ve softened on &amp;ldquo;main is always deployable&amp;rdquo; and let the
trunk act as the develop branch, with tagged releases as the actual source of
truth. For compiled languages, where the artefact you ship is a built, tagged
thing and not whatever&amp;rsquo;s on a server right now, that finally clicks.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d already rolled this out on my Go and Terraform projects with
&lt;a class="link" href="https://github.com/apricote/releaser-pleaser" target="_blank" rel="noopener"
 &gt;releaser-pleaser&lt;/a&gt;, a GitLab-native
take on release-please: a bot keeps a Release MR open, and merging it cuts the
tag. It&amp;rsquo;s the same model I wrote about when
&lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;the infra repo moved to plan-on-merge, apply-on-tag&lt;/a&gt;.
Lovely. Then I came to do the same for rust-tool-base, and Rust, being Rust,
&lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;had opinions&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="rust-brings-its-own-toolchain"&gt;Rust brings its own toolchain
&lt;/h2&gt;&lt;p&gt;releaser-pleaser is happy to tag a repo and write a release. What it does not do
is &lt;code&gt;cargo publish&lt;/code&gt; seventeen crates to crates.io in dependency order. Rust&amp;rsquo;s
release story isn&amp;rsquo;t &amp;ldquo;push a tag and let a runner build a binary&amp;rdquo;, it&amp;rsquo;s a whole
publishing pipeline with a public registry at the end of it, and that registry
has rules of its own. So for the Rust workspace I reached for the tool built for
exactly that job: &lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt;. Same Release-MR shape,
but it understands cargo, versions every crate, and publishes the lot.&lt;/p&gt;
&lt;p&gt;That was the right call. Getting it to actually do it was where I spent two days
I&amp;rsquo;d quite like back.&lt;/p&gt;
&lt;h2 id="the-gauntlet-before-the-gun"&gt;The gauntlet before the gun
&lt;/h2&gt;&lt;p&gt;Before I got anywhere near the interesting failure, there was a run of CI
papercuts, the sort where every fix politely reveals the next one. GitLab checks
out a detached HEAD, and release-plz wants to be on a branch (&amp;ldquo;HEAD does not
point to a branch&amp;rdquo;), so you re-attach. Then the default &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; can&amp;rsquo;t push
to a protected repo, so you point the remote at a real token. Then release-plz
assumes you&amp;rsquo;re on GitHub and errors that the repo &amp;ldquo;is not hosted in GitHub&amp;rdquo;, so
you tell it &lt;code&gt;--forge gitlab&lt;/code&gt;. Then it refuses to run at all because the &lt;code&gt;pages&lt;/code&gt;
job left a &lt;code&gt;public/&lt;/code&gt; directory lying about and the working tree is &amp;ldquo;dirty&amp;rdquo;, so
you stop pulling artefacts into the job.&lt;/p&gt;
&lt;p&gt;Five merge requests before the thing would even &lt;em&gt;start&lt;/em&gt; doing its actual job.
You can read the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/.gitlab-ci.yml#L379-L409" target="_blank" rel="noopener"
 &gt;scar tissue in the &lt;code&gt;before_script&lt;/code&gt;&lt;/a&gt;;
every line in it is a fix for something on that list. None of it was hard.
It was just death by a thousand cuts, and I was feeling quite smug by the time
it finally reached the publish step.&lt;/p&gt;
&lt;p&gt;I should not have been.&lt;/p&gt;
&lt;h2 id="tag-v051-already-exists"&gt;&amp;ldquo;Tag v0.5.1 already exists&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;My &lt;code&gt;release-plz.toml&lt;/code&gt; asked for
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;one tag per release&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That felt obviously right. It matched the repo&amp;rsquo;s existing &lt;code&gt;v0.5.0&lt;/code&gt; tag, it&amp;rsquo;s how
a single-crate project tags, and the crates all share one workspace version
anyway. One version, one tag. What&amp;rsquo;s to argue with?&lt;/p&gt;
&lt;p&gt;release-plz, that&amp;rsquo;s what. It tags &lt;em&gt;per crate&lt;/em&gt;. So it publishes a crate, creates
the tag, publishes the next crate, and tries to create the same tag again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO published rtb-assets 0.5.1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to release package
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Caused by:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975a75...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1: Response body: { &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 2: HTTP status client error (400 Bad Request) ... /repository/tags
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The collision is annoying. What makes it a proper trap is the half-second before
it: &lt;code&gt;published rtb-assets 0.5.1&lt;/code&gt;. That happened. On crates.io. For keeps. A
crates.io publish is forever, there is no unpublish, only a yank that still
leaves the name and version burned. So every time my flaky pipeline limped one
crate further and then fell over on the tag, it left another crate live on the
public registry that I could never take back. By the time the dust settled, six
of the seventeen were out there: &lt;code&gt;rtb-assets&lt;/code&gt; and &lt;code&gt;rtb-config&lt;/code&gt;, then on a later
retry &lt;code&gt;rtb-credentials&lt;/code&gt; and &lt;code&gt;rtb-error&lt;/code&gt;, then &lt;code&gt;rtb-app&lt;/code&gt; and &lt;code&gt;rtb-redact&lt;/code&gt;. Two
more permanent crates per failed run.&lt;/p&gt;
&lt;h2 id="i-assumed-the-default"&gt;I assumed the default
&lt;/h2&gt;&lt;p&gt;My first fix was the clever one, and it deserves to be on display because it&amp;rsquo;s
the whole lesson in miniature. I deleted the &lt;code&gt;git_tag_name&lt;/code&gt; line. My reasoning:
per-crate tags are release-plz&amp;rsquo;s native model, so surely its &lt;em&gt;default&lt;/em&gt; does the
right thing without me spelling it out. I was confident enough to write it into
the commit message: &amp;ldquo;per-crate tags/releases (release-plz defaults).&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The next run collided on &lt;code&gt;v0.5.1&lt;/code&gt;, exactly as before.&lt;/p&gt;
&lt;p&gt;Because release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; is not per-crate. It&amp;rsquo;s the unified
&lt;code&gt;v{{ version }}&lt;/code&gt;. I had deleted a line that said the wrong thing and replaced it
with a default that said the &lt;em&gt;same&lt;/em&gt; wrong thing, then congratulated myself for
tidiness. If I&amp;rsquo;d spent thirty seconds on the configuration reference instead of
thirty seconds being clever, I&amp;rsquo;d have read that in black and white.&lt;/p&gt;
&lt;h2 id="same-config-two-answers"&gt;Same config, two answers
&lt;/h2&gt;&lt;p&gt;So I read the manual, and set it explicitly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On my laptop, a dry run rendered exactly the per-crate tags I wanted. In CI, the
very next run published another crate and then created the tag &lt;code&gt;v0.5.1&lt;/code&gt;. The
unified one. The wrong one. The one I had just, demonstrably, on main,
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;replaced&lt;/a&gt;.
Same &lt;code&gt;release-plz.toml&lt;/code&gt;, two completely different answers depending on who was
asking.&lt;/p&gt;
&lt;p&gt;That one took me an embarrassingly long time to see. release-plz does not read
your config from the working tree when it runs a &lt;em&gt;release&lt;/em&gt;. It reads it from the
&lt;strong&gt;release commit&lt;/strong&gt;, the commit that introduced the version it&amp;rsquo;s releasing. My
version was still &lt;code&gt;0.5.1&lt;/code&gt;, set days earlier on a commit that still carried the
unified template. You can see it in the failure: the tag it tries to create is
pinned to &lt;code&gt;ref 'f6de975...'&lt;/code&gt;, an old commit, not the HEAD that held my fix.
Every edit I made at the tip of main was real, committed, and utterly invisible
to the release of 0.5.1, because no version bump had created a fresh release
commit for it to read. My fix was correct and inert at the same time. The
dry run read my working directory and looked perfect; CI read history and did
something else.&lt;/p&gt;
&lt;p&gt;There is no config change that rescues an in-flight release. The version was
already out, half-published, tagged wrong, and pointed at a commit I couldn&amp;rsquo;t
edit without bumping the version, which I couldn&amp;rsquo;t cleanly do with six crates
already live.&lt;/p&gt;
&lt;h2 id="doing-it-the-way-id-have-done-it-a-year-ago"&gt;Doing it the way I&amp;rsquo;d have done it a year ago
&lt;/h2&gt;&lt;p&gt;So I stopped. Three retries deep, each one a seventy-minute CI cycle thrown at an
opaque mismatch, six crates already immovable on crates.io, and a tooling problem
I now understood well enough to know the tool was never going to dig me out of
&lt;em&gt;this particular&lt;/em&gt; hole. The question quietly changed from &amp;ldquo;why is it doing this?&amp;rdquo;
to &amp;ldquo;am I going to keep grinding, or finish this the way I would have before I had
clever tooling?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;I went manual. &lt;code&gt;cargo publish&lt;/code&gt;, the remaining eleven, by hand, in dependency
order: the leaf crates first and the &lt;code&gt;rust-tool-base&lt;/code&gt; umbrella dead last,
because it depends on all of them. crates.io rate-limits new crate names, so
after a burst it simply made me wait, a roughly half-hour pause in the middle
while the registry caught its breath and I caught mine. Then one &lt;code&gt;v0.5.1&lt;/code&gt; tag,
cut by hand, and one GitLab release to match the convention. The next CI run came
up green, for the gloriously dull reason that there was nothing left to do:
every crate published, the tag already there.&lt;/p&gt;
&lt;h2 id="stop-being-clever-and-rtfm"&gt;Stop being clever and RTFM
&lt;/h2&gt;&lt;p&gt;The tool was never broken. Every single thing it did was documented behaviour I
hadn&amp;rsquo;t bothered to read: that the default tag template is unified, that the model
is per-crate, that a release reads its config from the release commit and not
from HEAD. I assumed my way past the manual three times in a row, and each
assumption cost me real, permanent state on a public registry that doesn&amp;rsquo;t take
returns.&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s the part that actually stung, because I should have known better than
most. I wasn&amp;rsquo;t a beginner here. I knew the Release-MR pattern cold, I&amp;rsquo;d shipped it
half a dozen times with releaser-pleaser on my Go and Terraform repos. That
familiarity &lt;em&gt;was&lt;/em&gt; the trap. I trusted the pattern and skipped the tool, on the
lazy assumption that something I understood well in one tool would behave the same
in the next. release-plz carries the same design, but it&amp;rsquo;s a different tool, with
its own defaults and its own idea of where the config lives. The pattern came
across fine. The mechanics didn&amp;rsquo;t, and I never thought to check.&lt;/p&gt;
&lt;p&gt;So here&amp;rsquo;s the lesson, written down in the hope it sticks this time: no matter how
familiar I am with a pattern or a design, the moment I switch the tool that
implements it, reading the manual is paramount. The familiarity is exactly what
tempts you to skip it, and exactly why you can&amp;rsquo;t. (The narrower, more practical
one, while I&amp;rsquo;m here: a config change that affects how a release behaves has to
travel &lt;em&gt;with&lt;/em&gt; a version bump, or it sits there looking applied and doing nothing.)&lt;/p&gt;
&lt;p&gt;release-plz is genuinely good, and every release since has gone out clean on the
first try, the way &lt;a class="link" href="https://phpboyscout.uk/from-allow-failure-to-blocking/" &gt;the rest of the CI now does&lt;/a&gt;.
I just had to stop being clever long enough to read how it actually works. RTFM.
I&amp;rsquo;ll get it tattooed eventually.&lt;/p&gt;</description></item><item><title>The security service I had to switch off</title><link>https://phpboyscout.uk/the-security-service-i-had-to-switch-off/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-security-service-i-had-to-switch-off/</guid><description>&lt;img src="https://phpboyscout.uk/the-security-service-i-had-to-switch-off/cover-the-security-service-i-had-to-switch-off.png" alt="Featured image of post The security service I had to switch off" /&gt;&lt;p&gt;A while back I wrote about &lt;a class="link" href="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/" &gt;hardening the account that would hold the signing
key&lt;/a&gt;,
and one line in it has aged badly. &amp;ldquo;GuardDuty is already looking,&amp;rdquo; I wrote: the
account watched from day one, threat detection on before the key even arrives.
Then I went to apply that baseline to a brand-new account, and GuardDuty wasn&amp;rsquo;t
looking at all, because the account wouldn&amp;rsquo;t let it.&lt;/p&gt;
&lt;h2 id="what-the-baseline-switches-on"&gt;What the baseline switches on
&lt;/h2&gt;&lt;p&gt;The baseline runs a &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/threat-detection/main.tf#L24-66" target="_blank" rel="noopener"
 &gt;threat-detection module&lt;/a&gt;
that turns on three AWS services: GuardDuty, Security Hub, and IAM Access
Analyzer. Each sits behind an &lt;code&gt;enable_*&lt;/code&gt; toggle, all defaulting to on, which is
the right default. An account about to hold something sensitive should be
watched, flagged and analysed from the start. The hardening post&amp;rsquo;s whole
argument was that you fit the locks before you move the valuables in, and I
stand by it. The gap I hadn&amp;rsquo;t accounted for is the one between a posture you can
&lt;em&gt;design&lt;/em&gt; and a posture a fresh account will actually &lt;em&gt;run&lt;/em&gt; on its first day.&lt;/p&gt;
&lt;h2 id="subscriptionrequiredexception"&gt;SubscriptionRequiredException
&lt;/h2&gt;&lt;p&gt;GuardDuty and Security Hub are first-party AWS services, but they are not &lt;em&gt;on&lt;/em&gt;
by default. They have to be activated for the account before anything can
configure them. Point OpenTofu at a brand-new account that has never had them
switched on and you don&amp;rsquo;t get a security baseline, you get a
&lt;code&gt;SubscriptionRequiredException&lt;/code&gt; and a failed apply. The locks you carefully
specced won&amp;rsquo;t fit, because the door hasn&amp;rsquo;t been registered as a door yet.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d actually met this exact wall from another direction: an aws-nuke dry-run on
a fresh account &lt;a class="link" href="https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/" &gt;throws screenfuls of the same exception&lt;/a&gt;
for every service you&amp;rsquo;ve never enabled. Same root cause, different tool. A new
AWS account is a quieter, emptier place than the console makes it look.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second, duller reason too, and it belongs in the open: GuardDuty and
Security Hub both cost money once their free trial lapses, and the budget for
this account wasn&amp;rsquo;t in place yet. Enabling a service you can&amp;rsquo;t yet afford to
keep on is its own small mistake.&lt;/p&gt;
&lt;h2 id="switching-two-watchers-back-off-on-purpose"&gt;Switching two watchers back off, on purpose
&lt;/h2&gt;&lt;p&gt;So I did the thing that felt wrong and was right. I switched two of them off,
and wrote the reason straight into the module call:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# GuardDuty + Security Hub are deferred. The account&amp;#39;s first-time
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# service activation is pending (it returns SubscriptionRequiredException)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# and the services carry an ongoing cost beyond their free trial.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Re-enable by flipping both to true (or deleting these lines) once
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# activation clears and the budget is in place.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enable_guardduty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;enable_securityhub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;false&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two things make that a deferral rather than a retreat. First, it&amp;rsquo;s scoped:
Access Analyzer needs no subscription and costs nothing, so it stays on. The
account isn&amp;rsquo;t unwatched, it&amp;rsquo;s watched by the part that &lt;em&gt;can&lt;/em&gt; watch it right now.
Second, it carries its own undo. The comment is the re-enable instructions, the
toggles sit right there, and flipping them back is a one-line change the day
activation clears and the budget lands. A disabled control with a written path
back is a deferral. A disabled control with no note is a hole someone finds in a
year.&lt;/p&gt;
&lt;h2 id="why-this-lives-in-the-module-not-in-a-hack"&gt;Why this lives in the module, not in a hack
&lt;/h2&gt;&lt;p&gt;I could only do this cleanly because the module
&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/variables.tf#L68-86" target="_blank" rel="noopener"
 &gt;exposes each service as its own toggle&lt;/a&gt;,
and it only gained that recently; the previous version was all-or-nothing on
threat detection. Granular &lt;code&gt;enable_guardduty&lt;/code&gt; and &lt;code&gt;enable_securityhub&lt;/code&gt; flags are
exactly what let you say &amp;ldquo;this account, these two, not yet&amp;rdquo; without forking the
module or commenting resources out. It&amp;rsquo;s the difference between a baseline you
adapt per account and one you fight.&lt;/p&gt;
&lt;h2 id="the-honest-version-of-secure-by-default"&gt;The honest version of &amp;ldquo;secure by default&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The hardening post wasn&amp;rsquo;t wrong. It was idealised. Secure-by-default is the
right aim, and on a mature account the baseline goes on clean. On a brand-new
one, reality intrudes: services that must be activated before they can be
configured, costs that need a budget before they can be incurred. The honest
response isn&amp;rsquo;t to pretend the posture is fully live when it isn&amp;rsquo;t. It&amp;rsquo;s to
enable what the account will take, defer what it won&amp;rsquo;t, write down precisely why
and how to finish, and leave Access Analyzer watching in the meantime. GuardDuty
will be looking soon enough. It just wasn&amp;rsquo;t looking on day one, and saying so is
better than a comment-free &lt;code&gt;enable = true&lt;/code&gt; that quietly errored on every apply.&lt;/p&gt;</description></item><item><title>From allow_failure to blocking</title><link>https://phpboyscout.uk/from-allow-failure-to-blocking/</link><pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/from-allow-failure-to-blocking/</guid><description>&lt;img src="https://phpboyscout.uk/from-allow-failure-to-blocking/cover-from-allow-failure-to-blocking.png" alt="Featured image of post From allow_failure to blocking" /&gt;&lt;p&gt;There&amp;rsquo;s a special kind of CI job that everyone on a team quietly learns to
ignore: the one marked &lt;code&gt;allow_failure: true&lt;/code&gt;. It runs, it goes red, the
pipeline goes green anyway, and after the third time you stop looking at it. I
inherited six of those when I moved rust-tool-base&amp;rsquo;s CI to GitLab. Over a few
days I turned three of them into real gates, and the interesting part was never
the YAML. It was working out which ones had earned the right to block, and
which hadn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-allow_failure-actually-buys-you"&gt;What allow_failure actually buys you
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is genuinely useful, and quietly corrosive. It lets a job
report a problem without stopping the pipeline, which is exactly right for a
check that&amp;rsquo;s noisy, not yet stable, or guarding against something you can&amp;rsquo;t fix
this minute. The trouble is that a warning nobody is forced to act on is a
warning nobody acts on. Leave a job advisory long enough and it becomes
scenery: red, ignored, pointless. So an advisory check is really a promise,
&amp;ldquo;I&amp;rsquo;ll make this blocking once it&amp;rsquo;s trustworthy&amp;rdquo;, and a promise you only ever
mean to keep is just a lie you haven&amp;rsquo;t noticed yet.&lt;/p&gt;
&lt;p&gt;When I &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/2213f8e/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;migrated rust-tool-base from GitHub Actions to GitLab CI&lt;/a&gt;,
the move landed six jobs as &lt;code&gt;allow_failure: true&lt;/code&gt;: the macOS and Windows tests,
the integration tests, &lt;code&gt;cargo-audit&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;, and coverage. That wasn&amp;rsquo;t
laziness. A migration is the wrong moment to also be fighting flaky gates. But
it left me holding six promises to either keep or admit I wasn&amp;rsquo;t going to.&lt;/p&gt;
&lt;h2 id="a-check-earns-the-right-to-block"&gt;A check earns the right to block
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the rule I settled on. A check earns the right to fail your build when
two things are true: it&amp;rsquo;s &lt;em&gt;meaningful&lt;/em&gt; (a red result is a real problem, not
noise) and it&amp;rsquo;s &lt;em&gt;reliable&lt;/em&gt; (it goes red only when there genuinely is a problem,
and it can actually run to completion). Flip a check to blocking before both
hold and you haven&amp;rsquo;t raised the bar, you&amp;rsquo;ve taught the team to force-merge past
red, which is worse than no gate at all, because now the red means nothing.&lt;/p&gt;
&lt;p&gt;Three of my six crossed that line within a few days. Three deliberately didn&amp;rsquo;t.
The reasons are the whole story.&lt;/p&gt;
&lt;h2 id="trivy-blocked-once-there-was-nothing-to-block-on"&gt;trivy: blocked once there was nothing to block on
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f9cab20/.gitlab-ci.yml#L247-256" target="_blank" rel="noopener"
 &gt;&lt;code&gt;trivy&lt;/code&gt;&lt;/a&gt;
scans the dependency tree for HIGH and CRITICAL advisories. It went across as
advisory for an honest reason: the &lt;code&gt;Cargo.lock&lt;/code&gt; at migration time already
carried two known HIGH/CRITICAL advisories I hadn&amp;rsquo;t cleared yet, a
path-traversal in &lt;code&gt;gix-validate&lt;/code&gt; and a DNS-rebinding issue in &lt;code&gt;rmcp&lt;/code&gt;. Make
trivy blocking with those sitting there and the pipeline is red from day one,
over problems I already knew about and was already fixing. So it stayed
advisory until the dependency bumps cleared both, and then the &lt;code&gt;allow_failure&lt;/code&gt;
line came out. The gate never changed. The tree underneath it got clean enough
to stand on.&lt;/p&gt;
&lt;h2 id="integration-tests-blocked-once-it-could-actually-run"&gt;integration-tests: blocked once it could actually run
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;integration tests&lt;/a&gt;
stand up a real Gitea in a Docker-in-Docker service and talk to it. They were
advisory for a different reason: they couldn&amp;rsquo;t reliably run. dind needs a
privileged runner, and the suite was resolving the container host with a
hardcoded &lt;code&gt;127.0.0.1&lt;/code&gt; that didn&amp;rsquo;t hold everywhere. Blocking a job that fails
for infrastructure reasons rather than code reasons is the fastest way to make
people distrust the entire pipeline. So the fix wasn&amp;rsquo;t in the YAML, it was
making the thing dependable: &lt;code&gt;privileged&lt;/code&gt; set on the runner, and the host
resolved through the test library&amp;rsquo;s own &lt;code&gt;get_host()&lt;/code&gt; instead of a hardcoded
address. Once it ran the same way every time, it earned the gate.&lt;/p&gt;
&lt;h2 id="coverage-blocked-once-it-could-run-at-all-then-once-it-cleared-the-bar"&gt;coverage: blocked once it could run at all, then once it cleared the bar
&lt;/h2&gt;&lt;p&gt;Coverage is the two-step one, and my favourite, because it nearly didn&amp;rsquo;t make
it for a thoroughly undramatic reason: it ran out of memory. &lt;code&gt;cargo llvm-cov&lt;/code&gt;
instruments every test binary, and linking hundreds of instrumented object
files needs more RAM than the shared medium runner had, so the job bus-errored
on the link. I tagged it onto a larger runner, and then the shared SaaS runners
were switched off entirely, so the tag matched nothing and the job sat pending
forever.&lt;/p&gt;
&lt;p&gt;The fix was a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;self-hosted homelab runner&lt;/a&gt;
with the RAM the instrumented link actually needs. I moved coverage there but
kept it advisory &lt;em&gt;for one run&lt;/em&gt;, to confirm the box could finish the build
before I trusted it. It did, at
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/1c9e589/.gitlab-ci.yml#L286-320" target="_blank" rel="noopener"
 &gt;73.22% line coverage&lt;/a&gt;,
so I set the gate to fail under 70% and made it blocking. Three points of
headroom: enough that ordinary churn won&amp;rsquo;t trip it, tight enough that a real
drop will. A coverage gate pinned to the current number is a tripwire that
fires on the very next commit; set it a touch below and it catches regressions
instead of normal life.&lt;/p&gt;
&lt;h2 id="the-three-i-left-advisory-on-purpose"&gt;The three I left advisory, on purpose
&lt;/h2&gt;&lt;p&gt;The point was never &amp;ldquo;block everything&amp;rdquo;. Three jobs are still &lt;code&gt;allow_failure&lt;/code&gt; in
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/d3c23fc/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;the current pipeline&lt;/a&gt;,
deliberately. The macOS and Windows tests run on SaaS runners that bill by the
minute; they&amp;rsquo;re worth running, not worth blocking every merge of a Linux-first
project over a quota I&amp;rsquo;m choosing to ration. And &lt;code&gt;cargo-audit&lt;/code&gt; stays advisory
because &lt;code&gt;cargo-deny&lt;/code&gt; already does the blocking advisory check: cargo-audit is a
second opinion from a different database, and a second opinion that can veto
isn&amp;rsquo;t a second opinion, it&amp;rsquo;s a duplicate gate that will eventually disagree with
the first and block you on the difference.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the same rule from the other side. Those three haven&amp;rsquo;t earned the right
to block, because blocking them would cost more than it ever caught.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is fine as a waiting room and corrosive as a destination.
Every advisory check is a promise to make it blocking once it&amp;rsquo;s both meaningful
and reliable, and the job is to keep the promise or admit you won&amp;rsquo;t. trivy
earned its gate when the advisories cleared, the integration tests when they
ran the same way every time, coverage when it had a runner with enough memory
and a threshold set just below the current mark. The three I left advisory
earned that standing too, by costing more to block than they&amp;rsquo;d catch. The YAML
is one deleted line per job. Knowing which line to delete, and when, is the
whole skill.&lt;/p&gt;</description></item><item><title>Two bugs that taught me the rules</title><link>https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/</guid><description>&lt;img src="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/cover-two-bugs-that-taught-me-the-rules.png" alt="Featured image of post Two bugs that taught me the rules" /&gt;&lt;p&gt;Some bugs are interesting because they&amp;rsquo;re subtle. These two were interesting because they were the exact opposite&amp;hellip; in each case the tool had a hard rule I simply didn&amp;rsquo;t know about, and its error message couldn&amp;rsquo;t be bothered to tell me what that rule was. Both came out of building the infrastructure toolchain, both cost me a good deal more time than they had any right to, and both are the sort of thing that looks blindingly obvious the moment you know it and utterly baffling until you do.&lt;/p&gt;
&lt;p&gt;So here they are, written down, partly to save you the bother and partly so I don&amp;rsquo;t go and forget them myself.&lt;/p&gt;
&lt;h2 id="bug-one-the-rule-less-job-that-skips-your-merge-requests"&gt;Bug one: the rule-less job that skips your merge requests
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;cicd&lt;/code&gt; gate components, in their first cut, shipped with no &lt;code&gt;rules:&lt;/code&gt; block. They were dead simple jobs: lint, scan, validate. No conditions, because they should just always run. Obviously.&lt;/p&gt;
&lt;p&gt;They ran on branch pipelines. On merge requests, they didn&amp;rsquo;t run at all! The gates that were the entire point of the components were simply absent from the one place you&amp;rsquo;d most want to see them&amp;hellip; the merge request.&lt;/p&gt;
&lt;p&gt;The cause is a GitLab CI rule that&amp;rsquo;s remarkably easy to go years without ever learning: a job with no &lt;code&gt;rules:&lt;/code&gt; block runs only on branch and tag pipelines. It does not run on merge-request pipelines. So &amp;ldquo;no conditions&amp;rdquo; doesn&amp;rsquo;t mean &amp;ldquo;runs everywhere&amp;rdquo; at all. It means &amp;ldquo;runs everywhere except a merge request&amp;rdquo;, which is about the least intuitive default I can think of.&lt;/p&gt;
&lt;p&gt;The fix is faintly absurd, and that&amp;rsquo;s exactly what makes it stick. You add an &lt;em&gt;unconditional&lt;/em&gt; rule: &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-lint.yml#L38" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rules: [{ when: on_success }]&lt;/code&gt;&lt;/a&gt;. The content of that rule does precisely nothing. It always matches. What actually matters is that the job now &lt;em&gt;has&lt;/em&gt; a &lt;code&gt;rules:&lt;/code&gt; block at all, because merely having one is what makes a job eligible for merge-request pipelines. A rule whose content is meaningless, added solely so the block exists. That&amp;rsquo;s the fix. I&amp;rsquo;ll admit I stared at it for a moment.&lt;/p&gt;
&lt;h2 id="bug-two-the-import-block-that-only-works-at-the-root"&gt;Bug two: the import block that only works at the root
&lt;/h2&gt;&lt;p&gt;The second one came from &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;. The &lt;code&gt;account-hardening&lt;/code&gt; module needed to adopt a resource that already existed in the account, which is exactly what OpenTofu&amp;rsquo;s &lt;code&gt;import {}&lt;/code&gt; block is for. So an &lt;code&gt;import&lt;/code&gt; block went into the &lt;code&gt;account-hardening&lt;/code&gt; module, right next to the resource it was adopting. The natural home for it, surely.&lt;/p&gt;
&lt;p&gt;OpenTofu disagreed, and rejected it outright. The rule: an &lt;code&gt;import&lt;/code&gt; block is only allowed in the &lt;em&gt;root&lt;/em&gt; module. It can&amp;rsquo;t live inside a child module. A module that wants one of its own resources imported can&amp;rsquo;t declare that import itself&amp;hellip; the import has to be declared up at the root, and the root caller does the adopting.&lt;/p&gt;
&lt;p&gt;The fix was to take the &lt;code&gt;import&lt;/code&gt; block out of the module and document caller-side adoption instead. The module describes the resource, and the root configuration that calls the module is where the &lt;code&gt;import&lt;/code&gt; actually lives.&lt;/p&gt;
&lt;h2 id="the-shape-they-share"&gt;The shape they share
&lt;/h2&gt;&lt;p&gt;Two unrelated bugs, in two completely different tools, and the same shape sitting underneath both of them.&lt;/p&gt;
&lt;p&gt;In each case the tool has a hard structural rule. Where a block is allowed to live. What makes a job eligible for a particular kind of pipeline. And in each case the error told me the tool was unhappy without telling me &lt;em&gt;which&lt;/em&gt; rule I&amp;rsquo;d broken, so the obvious next move (debugging my own logic) was the wrong move entirely. There was nothing wrong with the logic. The thing was simply in a place the tool doesn&amp;rsquo;t allow, or missing a block the tool quietly insists on.&lt;/p&gt;
&lt;p&gt;The lasting lesson here isn&amp;rsquo;t the two specific rules, useful as they are to know. It&amp;rsquo;s the reflex. When something that should obviously work just doesn&amp;rsquo;t, and the error is unhelpful, stop debugging your logic and start suspecting a structural rule about &lt;em&gt;where&lt;/em&gt; something is allowed to be, or &lt;em&gt;whether&lt;/em&gt; a thing is eligible in the first place. GitLab CI and OpenTofu both have a handful of these, and you mostly learn them the hard way, by tripping over them. Knowing the shape of the category at least means the next one costs you an hour instead of a whole afternoon.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Two bugs from building the toolchain, one shape. A GitLab CI job with no &lt;code&gt;rules:&lt;/code&gt; block runs on branches and tags but silently not on merge requests, and the fix is an unconditional &lt;code&gt;rules:&lt;/code&gt; block whose content does nothing and whose mere existence is the entire point. An OpenTofu &lt;code&gt;import&lt;/code&gt; block gets rejected inside a child module, because imports are only legal at the root, so the caller adopts and the module just describes.&lt;/p&gt;
&lt;p&gt;Neither error named the rule it was enforcing, and that&amp;rsquo;s the category to watch for. When sound logic fails against an unhelpful error, suspect a structural rule about where a thing may live or whether it&amp;rsquo;s even eligible&amp;hellip; not a bug in what you actually wrote. It&amp;rsquo;ll save you an afternoon. It certainly cost me a couple.&lt;/p&gt;</description></item><item><title>Reviewed, then applied</title><link>https://phpboyscout.uk/reviewed-then-applied/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/reviewed-then-applied/</guid><description>&lt;img src="https://phpboyscout.uk/reviewed-then-applied/cover-reviewed-then-applied.png" alt="Featured image of post Reviewed, then applied" /&gt;&lt;p&gt;The genuinely dangerous moment in infrastructure-as-code isn&amp;rsquo;t the apply. It&amp;rsquo;s the gap between the plan a human read and approved, and the change that actually runs a moment later. If those two are different computations (and by default they are) then nobody really reviewed the thing that touched your account. The &lt;code&gt;infra&lt;/code&gt; repo closes that gap from both ends.&lt;/p&gt;
&lt;h2 id="the-gap-between-reviewed-and-ran"&gt;The gap between &amp;ldquo;reviewed&amp;rdquo; and &amp;ldquo;ran&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the moment in infrastructure-as-code where things go wrong.&lt;/p&gt;
&lt;p&gt;Someone opens a merge request. CI runs &lt;code&gt;tofu plan&lt;/code&gt; and the output is there to review: these three resources change, this one is destroyed. A human reads it, decides it&amp;rsquo;s correct, approves, merges. Then &lt;code&gt;apply&lt;/code&gt; runs.&lt;/p&gt;
&lt;p&gt;The trap is in what &lt;code&gt;apply&lt;/code&gt; actually applies. If &lt;code&gt;apply&lt;/code&gt; does its own fresh &lt;code&gt;tofu plan&lt;/code&gt; and then applies &lt;em&gt;that&lt;/em&gt;, the change that runs is not necessarily the change that was reviewed. State can have moved. A provider can have drifted. Someone else can have applied something in between. The reviewed plan and the applied change are two separate computations done at two different moments, and every difference between those moments is a change nobody looked at.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; closes that gap from both ends.&lt;/p&gt;
&lt;h2 id="plan-as-an-artifact"&gt;Plan as an artifact
&lt;/h2&gt;&lt;p&gt;The first end is making the reviewed plan and the applied plan the &lt;em&gt;same object&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-plan.yml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;tofu-plan&lt;/code&gt; component&lt;/a&gt; runs the plan and saves it. It writes &lt;code&gt;tfplan.cache&lt;/code&gt;, OpenTofu&amp;rsquo;s binary plan file, as a CI artifact. It also writes &lt;code&gt;tfplan.json&lt;/code&gt;, which GitLab renders as a plan widget right in the merge request: the add, change and destroy summary, there to review without leaving the MR.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-apply.yml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;tofu-apply&lt;/code&gt; component&lt;/a&gt; then does &lt;em&gt;not&lt;/em&gt; re-plan. It applies that saved &lt;code&gt;tfplan.cache&lt;/code&gt;. And OpenTofu itself enforces the safety net: applying a stale plan file, one captured against a state that has since moved, is rejected by the tool. So what reaches the account is provably the plan that was reviewed, or it&amp;rsquo;s nothing at all. There&amp;rsquo;s no third option where something unreviewed slips through.&lt;/p&gt;
&lt;h2 id="applying-is-a-human-decision"&gt;Applying is a human decision
&lt;/h2&gt;&lt;p&gt;The second end is &lt;em&gt;when&lt;/em&gt; apply runs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; is trunk-based: it dropped the &lt;code&gt;develop&lt;/code&gt; branch and works on &lt;code&gt;main&lt;/code&gt;. But a naive trunk setup auto-applies every push to &lt;code&gt;main&lt;/code&gt;, which means there&amp;rsquo;s no human gate at all, just whatever the last merge happened to contain.&lt;/p&gt;
&lt;p&gt;So the gate is built explicitly. &lt;code&gt;releaser-pleaser&lt;/code&gt; keeps a release merge request open against &lt;code&gt;main&lt;/code&gt;. Ordinary merges to &lt;code&gt;main&lt;/code&gt; run plans but apply nothing. The apply happens only when a person &lt;em&gt;merges the release MR&lt;/em&gt;. Merging it cuts a release tag, and the tag pipeline is what runs &lt;code&gt;tofu-apply&lt;/code&gt;, against the plan banked by the latest &lt;code&gt;main&lt;/code&gt; pipeline.&lt;/p&gt;
&lt;p&gt;The effect is that the act of applying to the account is the deliberate, visible act of merging the release request. Nothing reaches the account because a commit landed. It reaches the account because a person decided a release should go out and merged it. (Which, after the &lt;a class="link" href="https://phpboyscout.uk/why-we-left-github-for-gitlab/" &gt;accidental &lt;code&gt;v2.0.0&lt;/code&gt;&lt;/a&gt; that kicked off the whole GitLab move, is a discipline I&amp;rsquo;d freshly relearned the value of.)&lt;/p&gt;
&lt;h2 id="the-guard-on-the-gate"&gt;The guard on the gate
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one more piece, because a gate is only as good as its precondition.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;verify-main-plan&lt;/code&gt; job blocks the release MR from being mergeable unless the latest &lt;code&gt;main&lt;/code&gt; pipeline is green. You can&amp;rsquo;t cut a release, and therefore can&amp;rsquo;t apply, on top of a &lt;code&gt;main&lt;/code&gt; whose plan didn&amp;rsquo;t even succeed. The human gate has its own gate: the thing you&amp;rsquo;re about to merge has to be standing on a known-good plan before you&amp;rsquo;re allowed to merge it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;The risk in infrastructure-as-code is the gap between the plan a human reviewed and the change that runs, because a re-plan at apply time is a different computation from the one that was approved.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; closes it twice over. &lt;code&gt;tofu-plan&lt;/code&gt; saves the plan as a &lt;code&gt;tfplan.cache&lt;/code&gt; artifact and renders it as a merge-request widget; &lt;code&gt;tofu-apply&lt;/code&gt; applies that exact artifact, and OpenTofu rejects it outright if the state has moved underneath it. And applying is gated on a human merging a &lt;code&gt;releaser-pleaser&lt;/code&gt; release request, not on a push, with a &lt;code&gt;verify-main-plan&lt;/code&gt; check making sure that request can only be merged on top of a green plan. What gets applied is what was reviewed, when a person decided it should be.&lt;/p&gt;</description></item><item><title>One graph, not micro-stacks</title><link>https://phpboyscout.uk/one-graph-not-micro-stacks/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-graph-not-micro-stacks/</guid><description>&lt;img src="https://phpboyscout.uk/one-graph-not-micro-stacks/cover-one-graph-not-micro-stacks.png" alt="Featured image of post One graph, not micro-stacks" /&gt;&lt;p&gt;Once an infrastructure repo has a few concerns in it (account hardening, the security baseline, the signing stack still to come) there&amp;rsquo;s a steady pressure to split them into separate stacks with separate state, and Terragrunt is right there to help you do it. The &lt;code&gt;infra&lt;/code&gt; repo keeps everything in one OpenTofu graph instead. The reason comes down to who enforces your dependency ordering: the engine, or you.&lt;/p&gt;
&lt;h2 id="the-pressure-to-split"&gt;The pressure to split
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo&amp;rsquo;s &lt;code&gt;src/&lt;/code&gt; has several concerns in it, and more coming, the signing stack among them. Once a repo reaches that point, there&amp;rsquo;s a steady pressure to split: one stack per concern, each with its own state file.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s an appealing pressure. Separate stacks feel modular. Each &lt;code&gt;apply&lt;/code&gt; touches less, so the blast radius of any one run is smaller. And Terragrunt exists, popular and well-regarded, precisely to orchestrate a fleet of separate stacks. The path is well trodden.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; didn&amp;rsquo;t take it. &lt;code&gt;src/&lt;/code&gt; is a single OpenTofu root stack: each concern is a &lt;code&gt;module&lt;/code&gt; block, in its own &lt;code&gt;main.&amp;lt;concern&amp;gt;.tf&lt;/code&gt; file, all sharing one state and one graph.&lt;/p&gt;
&lt;h2 id="what-one-graph-gives-you"&gt;What one graph gives you
&lt;/h2&gt;&lt;p&gt;The thing a single graph gives you is engine-enforced truth about ordering and data.&lt;/p&gt;
&lt;p&gt;Inside one OpenTofu graph, the tool builds the full dependency DAG itself. When the signing stack needs a value the security baseline produced, you reference it directly, &lt;code&gt;module.baseline.something&lt;/code&gt;, and OpenTofu &lt;em&gt;guarantees&lt;/em&gt; two things: the baseline is created before the thing that depends on it, and the value handed across is the current one from this same apply. Ordering and data-passing aren&amp;rsquo;t things you arranged. They&amp;rsquo;re facts the engine checks and enforces, every plan, every apply.&lt;/p&gt;
&lt;h2 id="what-splitting-costs"&gt;What splitting costs
&lt;/h2&gt;&lt;p&gt;Split &lt;code&gt;src/&lt;/code&gt; into per-concern stacks with separate state, and that guarantee is the thing you spend.&lt;/p&gt;
&lt;p&gt;Now one stack reads another&amp;rsquo;s outputs through &lt;code&gt;terraform_remote_state&lt;/code&gt;. That&amp;rsquo;s a lookup of a &lt;em&gt;snapshot&lt;/em&gt;: the other stack&amp;rsquo;s last applied state, whatever it was, whenever that was. It&amp;rsquo;s not a live edge in a graph. Ordering is no longer enforced by the engine either; it becomes something you arrange yourself, in CI stage sequencing or in Terragrunt&amp;rsquo;s own dependency blocks.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the trade, stated plainly. You give up a strong, engine-checked guarantee, and you buy back a weaker, hand-arranged imitation of it. Terragrunt is a good tool for managing that weaker world tidily. But the question worth asking first is whether you should be in the weaker world at all.&lt;/p&gt;
&lt;h2 id="when-splitting-is-genuinely-right"&gt;When splitting is genuinely right
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that splitting is always wrong. Separate states genuinely earn their place when concerns have different change cadences, different access boundaries, or different teams owning them: when you actively &lt;em&gt;want&lt;/em&gt; an apply of one to be unable to touch another, and you want different people holding different state.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; has none of those. It&amp;rsquo;s a single account, a single operator, one cohesive set of concerns. The only thing splitting would buy here is a smaller per-apply blast radius, and that&amp;rsquo;s better handled by reviewing the plan before it applies, which &lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;the next post&lt;/a&gt; is about, than by fragmenting the dependency graph. So &lt;code&gt;src/&lt;/code&gt; stays one graph, and Terragrunt was considered and deliberately not adopted.&lt;/p&gt;
&lt;h2 id="if-ordering-between-graphs-is-ever-needed"&gt;If ordering between graphs is ever needed
&lt;/h2&gt;&lt;p&gt;If &lt;code&gt;infra&lt;/code&gt; ever does genuinely need more than one stack, the plan isn&amp;rsquo;t Terragrunt. It&amp;rsquo;s to keep each stack a single strong graph internally, and to sequence the stacks with CI stages. Keep the engine-enforced guarantee where it&amp;rsquo;s strongest, inside each graph, and reach for hand-arranged ordering only at the one seam where it&amp;rsquo;s unavoidable.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A multi-concern infrastructure repo feels like it should be split into per-concern stacks, and Terragrunt is right there to manage the result. &lt;code&gt;infra&lt;/code&gt; keeps &lt;code&gt;src/&lt;/code&gt; as one OpenTofu graph instead.&lt;/p&gt;
&lt;p&gt;Inside one graph, OpenTofu enforces dependency ordering and passes current values across module boundaries as checked facts. Split into separate states and that becomes a &lt;code&gt;terraform_remote_state&lt;/code&gt; snapshot lookup plus ordering you arrange by hand: a weaker version of what you gave up. Splitting is right when concerns have different cadences, boundaries or owners; for a single-account, single-operator repo none of that applies, so the strong guarantee is worth keeping, and Terragrunt is the tool for a problem &lt;code&gt;infra&lt;/code&gt; chose not to have.&lt;/p&gt;</description></item><item><title>CI you include, not copy</title><link>https://phpboyscout.uk/ci-you-include-not-copy/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ci-you-include-not-copy/</guid><description>&lt;img src="https://phpboyscout.uk/ci-you-include-not-copy/cover-ci-you-include-not-copy.png" alt="Featured image of post CI you include, not copy" /&gt;&lt;p&gt;Every infrastructure repo runs the same CI: lint the OpenTofu, scan it, validate it, plan, apply. The first repo, you write that &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; by hand. The second, you copy it. By the third, you&amp;rsquo;ve got three copies of the same pipeline quietly drifting apart, which is the exact problem you&amp;rsquo;d never tolerate in application code. The &lt;code&gt;cicd&lt;/code&gt; repo is the fix, and it&amp;rsquo;s just the library-first instinct pointed at the pipeline.&lt;/p&gt;
&lt;h2 id="the-gitlab-ciyml-you-keep-copying"&gt;The &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; you keep copying
&lt;/h2&gt;&lt;p&gt;The infrastructure repos in this series all run the same CI gate jobs: format and validate the OpenTofu, lint it, scan it for security issues and secrets, and on the deploy side, plan and apply.&lt;/p&gt;
&lt;p&gt;The first repo, you write that &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; by hand. The second repo needs the same jobs, so you copy it. The third repo, you copy it again. Now there are three copies of the same pipeline, and they do what copies always do. They drift. A fix you make in one repo&amp;rsquo;s CI doesn&amp;rsquo;t reach the other two. A tightened scan rule lands in the repo you were working in and nowhere else. It&amp;rsquo;s the copy-paste problem, exactly as it shows up in application code, just written in YAML and therefore that bit easier to pretend isn&amp;rsquo;t code.&lt;/p&gt;
&lt;h2 id="gitlab-has-a-feature-for-exactly-this"&gt;GitLab has a feature for exactly this
&lt;/h2&gt;&lt;p&gt;GitLab CI/CD Components are the answer to that problem. A component is a reusable, versioned piece of pipeline that you publish, and other projects pull in with an &lt;code&gt;include:&lt;/code&gt; pinned to a version:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitlab.com/phpboyscout/cicd/tofu-lint@v0.5.0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s a library import, for pipeline. The component has a defined interface, a version, and a home in GitLab&amp;rsquo;s CI/CD Catalog. A consuming repo includes it instead of carrying its own copy, and when the component improves, the consumer moves a version pin rather than re-copying YAML.&lt;/p&gt;
&lt;h2 id="why-a-monorepo-of-components"&gt;Why a monorepo of components
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/tree/v0.5.0/templates" target="_blank" rel="noopener"
 &gt;&lt;code&gt;cicd&lt;/code&gt; repo&lt;/a&gt; holds all of the components together: &lt;code&gt;tofu-lint&lt;/code&gt;, &lt;code&gt;tofu-security&lt;/code&gt;, &lt;code&gt;tofu-validate&lt;/code&gt;, &lt;code&gt;tofu-plan&lt;/code&gt;, &lt;code&gt;tofu-apply&lt;/code&gt;, and more. One project, not one project per component.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate call, and the reason is how GitLab versions things. A version is a tag, and a tag belongs to a &lt;em&gt;project&lt;/em&gt;. A component&amp;rsquo;s version is its project&amp;rsquo;s tag. So a monorepo of components, versioned together as one tag stream, is the natural unit: a consumer pins &lt;code&gt;@v0.5.0&lt;/code&gt; and gets a known-good &lt;em&gt;set&lt;/em&gt; of components that were tested together, rather than juggling a separate version for each one.&lt;/p&gt;
&lt;h2 id="authoring-discipline"&gt;Authoring discipline
&lt;/h2&gt;&lt;p&gt;A component is a file under &lt;code&gt;templates/&lt;/code&gt;, and it opens with a &lt;code&gt;spec: inputs:&lt;/code&gt; block: the typed inputs, their defaults, the component&amp;rsquo;s public interface.&lt;/p&gt;
&lt;p&gt;The discipline that keeps the library usable is that a component must be consumer-agnostic. It never hardcodes a token, and it never names a particular consumer&amp;rsquo;s variable. Inputs have sensible defaults, and a consuming repo overrides them. A component that reaches out and assumes something about the repo including it is a component that works in one repo and surprises the next. An authoring guide in the repo keeps that consistent across everyone who adds a component.&lt;/p&gt;
&lt;h2 id="the-self-test-you-cannot-fully-write"&gt;The self-test you cannot fully write
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;cicd&lt;/code&gt; repo tests its own components with a self-test pipeline. It&amp;rsquo;s worth knowing where that self-test stops.&lt;/p&gt;
&lt;p&gt;When a repo tests its own components by running them in child pipelines, GitLab masks &lt;code&gt;$CI_PIPELINE_SOURCE&lt;/code&gt; as &lt;code&gt;parent_pipeline&lt;/code&gt;. A component&amp;rsquo;s &lt;code&gt;rules:&lt;/code&gt;, which often branch on the pipeline source to behave differently for a merge request than for a branch or a tag, therefore can&amp;rsquo;t be exercised honestly by the self-test: the source they&amp;rsquo;d branch on has been flattened. The self-test covers what it can, and the component &lt;code&gt;rules:&lt;/code&gt; are, in the end, validated by real consumers using them for real. That&amp;rsquo;s a genuine limit, and naming it is better than pretending the self-test proves more than it does. (It&amp;rsquo;s also, not coincidentally, the exact &lt;code&gt;rules:&lt;/code&gt; quirk that bit me in &lt;a class="link" href="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/" &gt;one of the two bugs&lt;/a&gt; I closed the series with.)&lt;/p&gt;
&lt;h2 id="the-same-instinct-again"&gt;The same instinct, again
&lt;/h2&gt;&lt;p&gt;This blog keeps circling the same instinct. go-tool-base exists because the same CLI scaffolding kept getting rewritten, so it was &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;extracted into a library&lt;/a&gt;. &lt;code&gt;cicd&lt;/code&gt; is that instinct pointed at the pipeline: the same gate jobs kept getting copied between repos, so they were extracted into a versioned, included library.&lt;/p&gt;
&lt;p&gt;Stop copy-pasting. Publish, version, include. It&amp;rsquo;s true for CLI code, and it turns out to be just as true for the YAML that builds and ships it.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;Every infrastructure repo needs the same CI, and copying the &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; between them produces copies that drift apart. GitLab CI/CD Components fix it: reusable, versioned pipeline that a repo &lt;code&gt;include:&lt;/code&gt;s and pins, instead of carrying its own copy.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cicd&lt;/code&gt; is a monorepo of those components, versioned together as one tag stream, because GitLab tags a project and a component&amp;rsquo;s version is its project&amp;rsquo;s tag. Components are authored consumer-agnostic, with typed &lt;code&gt;spec: inputs:&lt;/code&gt; and no hardcoded assumptions, and their &lt;code&gt;rules:&lt;/code&gt; are validated by real use because the self-test can&amp;rsquo;t see the pipeline source. It&amp;rsquo;s the library-first instinct, applied to CI: publish it once, include it everywhere, fix it in one place.&lt;/p&gt;</description></item><item><title>One image for the whole toolchain</title><link>https://phpboyscout.uk/one-image-for-the-whole-toolchain/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-image-for-the-whole-toolchain/</guid><description>&lt;img src="https://phpboyscout.uk/one-image-for-the-whole-toolchain/cover-one-image-for-the-whole-toolchain.png" alt="Featured image of post One image for the whole toolchain" /&gt;&lt;p&gt;Every CI gate job across the infrastructure repos reaches for the same pile of tools: OpenTofu, tflint, trivy, checkov, gitleaks, terraform-docs, the AWS CLI. Installing that pile per job is both slow and quietly dangerous, because nothing pins it consistently. &lt;code&gt;infra-tools&lt;/code&gt; is the obvious fix (one image, one source of truth for versions), but two of its build decisions are less obvious and worth a look: it publishes with &lt;code&gt;crane&lt;/code&gt; instead of a second build, and it deliberately lets its own vulnerability scan fail.&lt;/p&gt;
&lt;h2 id="the-same-pile-of-tools-in-every-repo"&gt;The same pile of tools, in every repo
&lt;/h2&gt;&lt;p&gt;Every infrastructure repo in this series runs the same CI gate jobs: format and validate the OpenTofu, lint it, scan it for security problems and secrets, check the docs. Those jobs need a specific set of tools, and it&amp;rsquo;s the same set in every repo.&lt;/p&gt;
&lt;p&gt;Install them per job and you pay twice. You pay in time, because every pipeline downloads and installs the whole set again. And you pay in drift, because unless every repo pins every tool identically, the repos slowly diverge on which version of trivy or tflint they actually run, and a check that passes in one repo fails in another for no reason anyone can see.&lt;/p&gt;
&lt;h2 id="one-image-one-source-of-truth"&gt;One image, one source of truth
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;infra-tools&lt;/code&gt; is the answer: a single Debian-based container image with the whole toolchain baked in. Every CI job in every repo uses it with one &lt;code&gt;image:&lt;/code&gt; line.&lt;/p&gt;
&lt;p&gt;The real value isn&amp;rsquo;t the convenience. It&amp;rsquo;s that the image is the &lt;em&gt;one place&lt;/em&gt; tool versions are pinned. The Go-based tools are pinned in a &lt;code&gt;mise.toml&lt;/code&gt;. &lt;code&gt;checkov&lt;/code&gt;, which has no mise plugin, is pinned in a requirements file installed with pipx. The AWS CLI is pinned by a build argument. Three mechanisms, because the tools come from three kinds of source, but one image, and every pin wired to Renovate so a version bump arrives as a reviewable pull request. There&amp;rsquo;s exactly one answer to &amp;ldquo;what version of trivy does the toolchain use&amp;rdquo;, and it lives here.&lt;/p&gt;
&lt;h2 id="publishing-with-crane-not-a-second-build"&gt;Publishing with crane, not a second build
&lt;/h2&gt;&lt;p&gt;A build-pipeline detail that took a real bug to discover.&lt;/p&gt;
&lt;p&gt;The pipeline builds the image with kaniko, which builds images without a privileged Docker daemon, something that matters a great deal on shared CI runners. Then it scans the image, then it publishes it.&lt;/p&gt;
&lt;p&gt;The obvious way to write the publish stage is &amp;ldquo;build the image and push it&amp;rdquo;. But kaniko has no mode for &amp;ldquo;just push this tarball I already built&amp;rdquo;. A second kaniko invocation re-executes the entire Dockerfile from the top, including a second &lt;code&gt;mise install&lt;/code&gt;, which makes a fresh round of calls to GitHub&amp;rsquo;s API to fetch tools. GitHub&amp;rsquo;s anonymous API limit is low and shared by IP, so on a CI runner that second install reliably trips a &lt;code&gt;403&lt;/code&gt; rate-limit. (Yes, another &lt;code&gt;403&lt;/code&gt;. They do get everywhere.)&lt;/p&gt;
&lt;p&gt;So the publish stage doesn&amp;rsquo;t rebuild. It &lt;a class="link" href="https://gitlab.com/phpboyscout/images/infra-tools/-/blob/v0.2.0/.gitlab-ci.yml#L115" target="_blank" rel="noopener"
 &gt;uses &lt;code&gt;crane&lt;/code&gt;&lt;/a&gt; to push the exact image tarball the build stage already produced. The image is built once. And because the published bytes are the same bytes the scan stage scanned, there&amp;rsquo;s no gap between &amp;ldquo;the image we checked&amp;rdquo; and &amp;ldquo;the image we shipped&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="soft-failing-the-scanner-on-purpose"&gt;Soft-failing the scanner on purpose
&lt;/h2&gt;&lt;p&gt;The decision that looks wrong until you see the reasoning: the pipeline scans the image with trivy, and trivy is allowed to fail without failing the pipeline.&lt;/p&gt;
&lt;p&gt;A vulnerability scanner that doesn&amp;rsquo;t gate the build sounds like a scanner switched off. It isn&amp;rsquo;t. It&amp;rsquo;s a scanner pointed at something it can&amp;rsquo;t helpfully gate.&lt;/p&gt;
&lt;p&gt;The tools in the image are prebuilt Go binaries. trivy inspects them, reads the version of the Go runtime each was compiled with, and reports every known CVE in that Go runtime. Those findings are real, but they aren&amp;rsquo;t &lt;em&gt;mine&lt;/em&gt; to fix. The only fix is the upstream tool rebuilding itself against a patched Go. With seven such tools in the image, at any given moment one of them is usually a little behind on its Go version.&lt;/p&gt;
&lt;p&gt;A hard gate would mean the image becomes unpublishable whenever any single upstream lags, over a CVE in code I don&amp;rsquo;t own and can&amp;rsquo;t patch. That&amp;rsquo;s not a security control; it&amp;rsquo;s a way to be unable to ship. So the scan is &lt;code&gt;allow_failure&lt;/code&gt;. The findings stay fully visible, and the residual count is genuinely useful as a &lt;em&gt;metric&lt;/em&gt; for how far behind upstream the toolchain has drifted. It just doesn&amp;rsquo;t block shipping an image whose only &amp;ldquo;vulnerabilities&amp;rdquo; are other people&amp;rsquo;s build timelines.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;The infrastructure repos all run the same CI gate jobs, needing the same tools, so &lt;code&gt;infra-tools&lt;/code&gt; bakes the whole toolchain into one image and pins every version in one place, wired to Renovate.&lt;/p&gt;
&lt;p&gt;Two build choices are worth copying. The publish stage uses &lt;code&gt;crane&lt;/code&gt; to push the already-built, already-scanned tarball, because a second kaniko build would re-run &lt;code&gt;mise install&lt;/code&gt; and hit GitHub&amp;rsquo;s anonymous rate limit, and because pushing the scanned bytes means shipping exactly what was checked. And the trivy scan is deliberately &lt;code&gt;allow_failure&lt;/code&gt;, because it reports Go-runtime CVEs in prebuilt upstream binaries that no change to this repo can fix, so a hard gate would only make the image unshippable over someone else&amp;rsquo;s lag.&lt;/p&gt;</description></item><item><title>A 403 you can't fix in IAM</title><link>https://phpboyscout.uk/a-403-you-cant-fix-in-iam/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-403-you-cant-fix-in-iam/</guid><description>&lt;img src="https://phpboyscout.uk/a-403-you-cant-fix-in-iam/cover-a-403-you-cant-fix-in-iam.png" alt="Featured image of post A 403 you can't fix in IAM" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;The OIDC post&lt;/a&gt; explained the handshake that lets a GitLab pipeline deploy to AWS with no stored key. This is the story of the first time I got it wrong, and spent an afternoon fixing the wrong thing. The error was a flat 403 from AWS, and the maddening part is that no amount of editing the IAM policy was ever going to fix it.&lt;/p&gt;
&lt;h2 id="a-403-on-the-first-real-run"&gt;A 403 on the first real run
&lt;/h2&gt;&lt;p&gt;The OIDC post covered the handshake: GitLab CI mints a signed token, AWS exchanges it for short-lived credentials against a role whose trust policy names the pipeline. During the GitLab migration I wired exactly that up for the &lt;code&gt;infra&lt;/code&gt; repo, including a trust policy condition meant to let merge-request pipelines run a plan.&lt;/p&gt;
&lt;p&gt;The first merge request that should have triggered &lt;code&gt;tofu-plan&lt;/code&gt; didn&amp;rsquo;t run it. The job failed, and the error from AWS was a flat &lt;code&gt;AccessDenied&lt;/code&gt;. A 403.&lt;/p&gt;
&lt;h2 id="the-instinct-and-why-it-wastes-an-afternoon"&gt;The instinct, and why it wastes an afternoon
&lt;/h2&gt;&lt;p&gt;The instinct on an IAM 403 is immediate and almost always right: the policy&amp;rsquo;s wrong, so go and edit the policy. Tighten the condition. Loosen the condition. Check the wildcard. Re-read the &lt;code&gt;sub&lt;/code&gt; pattern character by character.&lt;/p&gt;
&lt;p&gt;All of that was wasted, and it was wasted for a reason that took me far too long to see. The trust policy wasn&amp;rsquo;t matching the &lt;em&gt;wrong&lt;/em&gt; value. It was matching a value that &lt;em&gt;does not exist&lt;/em&gt;. No amount of editing a condition makes it match a thing that&amp;rsquo;s never present.&lt;/p&gt;
&lt;h2 id="what-is-actually-in-the-token"&gt;What is actually in the token
&lt;/h2&gt;&lt;p&gt;GitLab&amp;rsquo;s OIDC token has a &lt;code&gt;sub&lt;/code&gt; claim that encodes the pipeline&amp;rsquo;s context, and part of that encoding is a &lt;code&gt;ref_type&lt;/code&gt;. I&amp;rsquo;d assumed &lt;code&gt;ref_type&lt;/code&gt; could be &lt;code&gt;branch&lt;/code&gt;, &lt;code&gt;tag&lt;/code&gt;, or &lt;code&gt;mr&lt;/code&gt;, because a pipeline can certainly be a branch pipeline, a tag pipeline, or a merge-request pipeline. So the trust policy, for the plan job, matched a &lt;code&gt;sub&lt;/code&gt; containing &lt;code&gt;ref_type:mr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That assumption was wrong. GitLab&amp;rsquo;s &lt;code&gt;ref_type&lt;/code&gt; is &lt;code&gt;branch&lt;/code&gt; or &lt;code&gt;tag&lt;/code&gt;. That&amp;rsquo;s the entire set. There is no &lt;code&gt;mr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A merge-request pipeline doesn&amp;rsquo;t run against a merge-request ref. It runs against the source &lt;em&gt;branch&lt;/em&gt;. So its token&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt; carries &lt;code&gt;ref_type:branch&lt;/code&gt;, like any other branch pipeline. The trust policy condition asked for &lt;code&gt;ref_type:mr&lt;/code&gt;, GitLab never puts &lt;code&gt;mr&lt;/code&gt; in a token, the condition was therefore never true, and every merge-request pipeline got a 403. Forever, until the policy stopped asking for a claim that isn&amp;rsquo;t real.&lt;/p&gt;
&lt;h2 id="the-fix-and-the-lesson-worth-more-than-the-fix"&gt;The fix, and the lesson worth more than the fix
&lt;/h2&gt;&lt;p&gt;The fix is small once it&amp;rsquo;s visible: match &lt;code&gt;ref_type:branch&lt;/code&gt; and narrow it down by branch name or project path instead. An afternoon of policy edits, and the actual change is one word.&lt;/p&gt;
&lt;p&gt;The lesson is the part worth keeping. When an OIDC trust fails, the useful question is never &amp;ldquo;is my policy clever enough&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;what&amp;rsquo;s &lt;em&gt;actually in the token&lt;/em&gt;&amp;rdquo;. An OIDC trust policy can only ever match the claims the identity provider genuinely asserts, and the gap between what a provider asserts and what you &lt;em&gt;assumed&lt;/em&gt; it asserts is precisely where this class of bug lives.&lt;/p&gt;
&lt;p&gt;So the move, when an OIDC handshake 403s, is to get hold of a real token and decode it. Look at the actual &lt;code&gt;sub&lt;/code&gt;, the actual claims, the actual values. Match what&amp;rsquo;s there. A 403 that survives every sensible edit to the policy is usually not a policy that&amp;rsquo;s too loose or too strict. It&amp;rsquo;s a policy matching a claim that was never going to be in the token.&lt;/p&gt;
&lt;h2 id="the-habit-it-left-behind"&gt;The habit it left behind
&lt;/h2&gt;&lt;p&gt;I wired an OIDC trust policy to let merge-request pipelines plan, by matching a &lt;code&gt;sub&lt;/code&gt; claim with &lt;code&gt;ref_type:mr&lt;/code&gt;. The first real merge request got a 403, and no edit to the policy fixed it, because GitLab&amp;rsquo;s &lt;code&gt;ref_type&lt;/code&gt; is only ever &lt;code&gt;branch&lt;/code&gt; or &lt;code&gt;tag&lt;/code&gt;. A merge-request pipeline runs on a branch ref, so the &lt;code&gt;mr&lt;/code&gt; value the policy demanded was never in any token.&lt;/p&gt;
&lt;p&gt;The fix was one word. The habit it left behind is the valuable bit: when an OIDC trust fails, stop editing the policy and go and read a real token. A trust policy can only match what the provider actually asserts, and &amp;ldquo;what I assumed it asserts&amp;rdquo; is where the 403 was hiding the whole time. (If this shape of bug feels familiar by the end of the series, that&amp;rsquo;s not an accident: I &lt;a class="link" href="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/" &gt;come back to it&lt;/a&gt; with two more from exactly the same family.)&lt;/p&gt;</description></item><item><title>Pure-Rust Git, no git binary</title><link>https://phpboyscout.uk/pure-rust-git-no-git-binary/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/pure-rust-git-no-git-binary/</guid><description>&lt;img src="https://phpboyscout.uk/pure-rust-git-no-git-binary/cover-pure-rust-git-no-git-binary.png" alt="Featured image of post Pure-Rust Git, no git binary" /&gt;&lt;p&gt;go-tool-base&amp;rsquo;s VCS support has two halves that get confused for one. One half talks to forge APIs (GitHub, GitLab) for releases and pull requests. The other talks to the &lt;code&gt;.git&lt;/code&gt; directory on disk: clone, history, diff, status. This post is mostly about the second half, and specifically about a question that turns out to have three answers in Rust, only one of which I&amp;rsquo;d recommend: how do you actually &lt;em&gt;do&lt;/em&gt; Git from inside a program?&lt;/p&gt;
&lt;h2 id="a-vcs-subsystem-with-two-halves"&gt;A VCS subsystem with two halves
&lt;/h2&gt;&lt;p&gt;go-tool-base has a VCS subsystem, and it does two distinct jobs.&lt;/p&gt;
&lt;p&gt;The first is forge APIs. GitHub and GitLab, Enterprise and nested group paths included. It authenticates, lists releases, fetches release assets, manages pull requests. The self-update machinery sits on this half, and it&amp;rsquo;s what a tool uses to ask &amp;ldquo;what&amp;rsquo;s the latest release?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The second is local Git. go-tool-base also carries a &lt;code&gt;RepoLike&lt;/code&gt; object, an abstraction over an actual Git repository on disk: clone it, read its commit history, diff two trees, check its status. This half doesn&amp;rsquo;t talk to a hosting service at all. It talks to the &lt;code&gt;.git&lt;/code&gt; directory.&lt;/p&gt;
&lt;p&gt;It would be easy to assume the second half grew out of the first. It didn&amp;rsquo;t, and where it actually came from is the part worth telling.&lt;/p&gt;
&lt;h2 id="a-capability-ahead-of-its-consumer"&gt;A capability ahead of its consumer
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;RepoLike&lt;/code&gt; object wasn&amp;rsquo;t built for go-tool-base. It came from another project, where it had already proved itself, and it was pulled into go-tool-base on purpose, with a specific future consumer in mind: the code generator.&lt;/p&gt;
&lt;p&gt;The plan is for the generator to use Git directly. When it scaffolds a new tool, that tool should start life as a Git repository, with a &lt;code&gt;git init&lt;/code&gt; and an initial commit. When you later regenerate, the generator should diff the regenerated template output against your working tree to detect drift, the same idea as &lt;a class="link" href="https://phpboyscout.uk/scaffolding-that-respects-your-edits/" &gt;respecting your edits&lt;/a&gt;. Both of those are local Git operations, not API calls, so the generator needs a repository abstraction to call into.&lt;/p&gt;
&lt;p&gt;That wiring isn&amp;rsquo;t finished yet. The generator doesn&amp;rsquo;t drive &lt;code&gt;RepoLike&lt;/code&gt; today. But the capability is in place, deliberately, ahead of the consumer that will use it, because the alternative is bolting Git support on later under deadline pressure, and that&amp;rsquo;s how you end up with the wrong abstraction.&lt;/p&gt;
&lt;p&gt;So when rust-tool-base was built, a repository abstraction was never in question. The Rust port carries the same capability for the same reason: a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-vcs/src/git/mod.rs#L69" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Repo&lt;/code&gt; type&lt;/a&gt; with &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;clone&lt;/code&gt;, &lt;code&gt;walk&lt;/code&gt;, &lt;code&gt;diff&lt;/code&gt;, &lt;code&gt;blame&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;checkout&lt;/code&gt;, present and ready for the generator to wire into. The open question was never &lt;em&gt;whether&lt;/em&gt; to have it. It was how to &lt;em&gt;do&lt;/em&gt; Git from inside a Rust program, and there are three answers, only one of which is any good.&lt;/p&gt;
&lt;h2 id="three-ways-to-do-git-and-the-one-worth-picking"&gt;Three ways to do Git, and the one worth picking
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Shell out to &lt;code&gt;git&lt;/code&gt;.&lt;/strong&gt; Run the &lt;code&gt;git&lt;/code&gt; binary as a subprocess and parse its output. It works until it doesn&amp;rsquo;t. The binary might not be installed. It might be a different version with different output. Its output is formatted for humans and changes between releases, so parsing it is a standing liability. You&amp;rsquo;ve made an undeclared dependency on a program you don&amp;rsquo;t ship.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Link libgit2.&lt;/strong&gt; libgit2 is the C library that reimplements Git as something you can call from code, and &lt;code&gt;git2&lt;/code&gt; is the Rust binding to it. It&amp;rsquo;s solid and widely used. But it&amp;rsquo;s a C dependency, which means a C toolchain in the build, and it&amp;rsquo;s consistently the single biggest source of cross-compilation pain in the Rust Git ecosystem. The musl builds, the Windows builds, the static linking: libgit2 is where they tend to break.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;gix&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;gix&lt;/code&gt; is a reimplementation of Git in pure Rust. No C library, no subprocess. It&amp;rsquo;s just Rust code, and it compiles and cross-compiles like any other crate, because that&amp;rsquo;s all it is. It&amp;rsquo;s also generally faster, and being pure Rust it fits the &lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;no-&lt;code&gt;unsafe&lt;/code&gt;-in-first-party-code&lt;/a&gt; story far more comfortably than dragging a C library along.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-vcs&lt;/code&gt; is &lt;code&gt;gix&lt;/code&gt;-first. The &lt;code&gt;Repo&lt;/code&gt; type is built on it. There&amp;rsquo;s no &lt;code&gt;git&lt;/code&gt; binary dependency, and there&amp;rsquo;s no libgit2 in a default build.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;gix&lt;/code&gt; is still maturing, and a few write paths, &lt;code&gt;push&lt;/code&gt; in particular, aren&amp;rsquo;t ready in it yet. For those, &lt;code&gt;git2&lt;/code&gt; (the libgit2 binding) is held in reserve as a documented fallback, to be wired behind an opt-in Cargo feature if and when a write path actually needs it. Until then a default build carries no libgit2 at all, and the common case, a tool that clones, reads history, diffs and commits, never pays its cross-compile cost. (The &lt;code&gt;gix&lt;/code&gt; backend itself sits behind an opt-in &lt;code&gt;git&lt;/code&gt; feature, which is &lt;a class="link" href="https://phpboyscout.uk/two-kinds-of-feature-flag/" &gt;exactly the feature-flag story&lt;/a&gt; from a couple of weeks back, doing real work.)&lt;/p&gt;
&lt;h2 id="repo-is-a-foundation-not-a-façade"&gt;&lt;code&gt;Repo&lt;/code&gt; is a foundation, not a façade
&lt;/h2&gt;&lt;p&gt;One design decision is worth calling out, because it came straight from a go-tool-base lesson.&lt;/p&gt;
&lt;p&gt;It would have been easy to build &lt;code&gt;Repo&lt;/code&gt; as a narrow façade exposing exactly what the scaffolder and the release-notes feature need today, and nothing else. That was rejected on purpose. go-tool-base&amp;rsquo;s &lt;code&gt;RepoLike&lt;/code&gt; is itself the cautionary tale: it arrived from another project, settled into a sensible abstraction, and is already lined up to carry a consumer, the generator, that wasn&amp;rsquo;t driving its design when it was first written. A repository abstraction gets used by code that doesn&amp;rsquo;t exist yet. Build one as a narrow façade around today&amp;rsquo;s needs and you&amp;rsquo;ve guaranteed a rewrite the first time a downstream tool wants something slightly different.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;rtb-vcs&lt;/code&gt;&amp;rsquo;s &lt;code&gt;Repo&lt;/code&gt; is built as a foundation: a sensible, reasonably complete vocabulary of Git operations that a tool author can compose richer behaviour on, without re-importing &lt;code&gt;gix&lt;/code&gt; directly and re-deriving the framework&amp;rsquo;s auth and concurrency conventions. The errors back this up. &lt;code&gt;gix&lt;/code&gt;&amp;rsquo;s error types aren&amp;rsquo;t leaked through the public API; they&amp;rsquo;re wrapped in semantic &lt;code&gt;RepoError&lt;/code&gt; variants, so the backend could be swapped, &lt;code&gt;gix&lt;/code&gt; to &lt;code&gt;git2&lt;/code&gt;, or to something else entirely, without breaking a single downstream caller.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s VCS support has two halves: forge-API calls for releases and pull requests, and a &lt;code&gt;RepoLike&lt;/code&gt; object for local Git operations. The repo half arrived from another project and is wired in ahead of its intended consumer, the code generator, which will use it to initialise repositories for scaffolded tools and to diff regenerated output for drift.&lt;/p&gt;
&lt;p&gt;rust-tool-base carries the same capability on purpose. Its &lt;code&gt;Repo&lt;/code&gt; type is built on &lt;code&gt;gix&lt;/code&gt;, a pure-Rust Git implementation, so there&amp;rsquo;s no dependency on an installed &lt;code&gt;git&lt;/code&gt; binary and no libgit2 C library in a default build, which keeps cross-compilation clean. &lt;code&gt;git2&lt;/code&gt; stays an opt-in fallback for the few write paths &lt;code&gt;gix&lt;/code&gt; can&amp;rsquo;t do yet. And &lt;code&gt;Repo&lt;/code&gt; is built as a foundation for downstream tools, with the backend wrapped behind its own error type so it can be replaced without breaking callers.&lt;/p&gt;</description></item><item><title>Routing security findings without the noise</title><link>https://phpboyscout.uk/routing-security-findings-without-the-noise/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/routing-security-findings-without-the-noise/</guid><description>&lt;img src="https://phpboyscout.uk/routing-security-findings-without-the-noise/cover-routing-security-findings-without-the-noise.png" alt="Featured image of post Routing security findings without the noise" /&gt;&lt;p&gt;Turning on GuardDuty and Security Hub gives you threat detection. It also gives you a firehose. And an alert system that dutifully forwards everything in that firehose isn&amp;rsquo;t monitoring, it&amp;rsquo;s a very efficient way of training your team to ignore alerts. So the &lt;code&gt;alerts&lt;/code&gt; module&amp;rsquo;s real job isn&amp;rsquo;t detection at all. It&amp;rsquo;s deciding what&amp;rsquo;s actually worth interrupting a human for, and the interesting part is everything it deliberately throws away.&lt;/p&gt;
&lt;h2 id="detection-is-the-easy-half"&gt;Detection is the easy half
&lt;/h2&gt;&lt;p&gt;Switching on threat detection in an AWS account is a few resources. GuardDuty, Security Hub with its standards, IAM Access Analyzer: &lt;a class="link" href="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/" &gt;the security baseline&lt;/a&gt; does exactly that. From then on, the account is generating findings.&lt;/p&gt;
&lt;p&gt;And it generates a lot of them. Plenty are low-severity, informational, or simply the normal texture of a cloud account. If you wire every finding to an email or a pager, you haven&amp;rsquo;t built monitoring. You&amp;rsquo;ve built noise. And noise has a specific failure mode: people stop reading it, and the one finding that genuinely mattered scrolls past unread alongside two hundred that didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;So the valuable work isn&amp;rsquo;t detection. It&amp;rsquo;s &lt;em&gt;routing&lt;/em&gt;: deciding what&amp;rsquo;s worth interrupting a human for, and letting the rest sit quietly in a console for whenever someone reviews it.&lt;/p&gt;
&lt;h2 id="forward-the-severe-leave-the-rest"&gt;Forward the severe, leave the rest
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module routes findings with EventBridge rules into an SNS topic that emails out. The rules are deliberately picky. &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/alerts/main.tf#L146" target="_blank" rel="noopener"
 &gt;GuardDuty findings are forwarded only at severity 7 and above&lt;/a&gt;. Security Hub findings are forwarded only at HIGH and CRITICAL.&lt;/p&gt;
&lt;p&gt;Everything below those thresholds isn&amp;rsquo;t discarded. It&amp;rsquo;s still in GuardDuty and Security Hub, where someone doing a review will see it. It just doesn&amp;rsquo;t get to interrupt anyone&amp;rsquo;s day. The threshold is the line between &amp;ldquo;look at this now&amp;rdquo; and &amp;ldquo;look at this sometime&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="the-duplicate-you-would-otherwise-send-twice"&gt;The duplicate you would otherwise send twice
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the subtle one, and it&amp;rsquo;s the kind of thing you only find by looking closely at where findings come from.&lt;/p&gt;
&lt;p&gt;Security Hub is an aggregator. It pulls findings &lt;em&gt;in&lt;/em&gt; from other services, GuardDuty among them. So a single GuardDuty finding can show up in two places: in GuardDuty itself, and again in Security Hub as an aggregated copy.&lt;/p&gt;
&lt;p&gt;A rule on GuardDuty findings and a rule on Security Hub HIGH/CRITICAL findings would therefore both fire for the same underlying GuardDuty finding. One event, two emails. Do that across an account and a meaningful fraction of your alert volume is just the same findings counted twice, which is its own kind of noise.&lt;/p&gt;
&lt;p&gt;So the Security Hub rule explicitly &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/alerts/main.tf#L178" target="_blank" rel="noopener"
 &gt;excludes findings whose &lt;code&gt;ProductName&lt;/code&gt; is GuardDuty, with an &lt;code&gt;anything-but&lt;/code&gt; match&lt;/a&gt;. GuardDuty findings come through the GuardDuty rule. The Security Hub rule handles everything Security Hub adds that GuardDuty didn&amp;rsquo;t already report. One finding, one alert, regardless of how many services it passed through.&lt;/p&gt;
&lt;h2 id="two-tripwires-on-the-root-account"&gt;Two tripwires on the root account
&lt;/h2&gt;&lt;p&gt;Findings are about threats the detectors recognise. The module adds two alarms about something simpler: the root account doing anything at all.&lt;/p&gt;
&lt;p&gt;One CloudWatch alarm fires on a root console sign-in. The other fires on any root API call that isn&amp;rsquo;t a console login. In a well-run AWS account, the root user does almost nothing after initial setup: day-to-day work happens through roles. So root activity isn&amp;rsquo;t a &amp;ldquo;finding&amp;rdquo; to be assessed for severity. It&amp;rsquo;s a tripwire. Any of it, in an account that should be silent, is worth an immediate look, and the two alarms say so directly.&lt;/p&gt;
&lt;h2 id="why-a-quiet-alert-stream-matters-here"&gt;Why a quiet alert stream matters here
&lt;/h2&gt;&lt;p&gt;This is monitoring for the account that&amp;rsquo;s going to hold the release-signing key, and that raises the stakes on getting the routing right.&lt;/p&gt;
&lt;p&gt;If a key-bearing account ever does come under attack, the alert that says so has to be &lt;em&gt;seen&lt;/em&gt;. An alert stream that&amp;rsquo;s mostly noise and duplicates is, functionally, no alerting at all, because the people who&amp;rsquo;d act on it have long since tuned it out. Routing the stream down to &amp;ldquo;severe, deduplicated, plus root tripwires&amp;rdquo; is what keeps it something a human will still read on the day it finally matters.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;GuardDuty and Security Hub make detection easy. The hard, valuable part is routing: forwarding what deserves to interrupt someone and leaving the rest in a console.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;alerts&lt;/code&gt; module forwards GuardDuty at severity 7-plus and Security Hub at HIGH/CRITICAL, and it drops the duplicate that aggregation creates by excluding GuardDuty-sourced findings from the Security Hub rule, so one finding is one alert. Two CloudWatch alarms act as tripwires on root-account activity, which should be near-zero. For the account that will hold the signing key, a quiet, trustworthy alert stream isn&amp;rsquo;t a nicety. It&amp;rsquo;s the difference between monitoring and theatre.&lt;/p&gt;</description></item><item><title>Why go-tool-base left GitHub for GitLab</title><link>https://phpboyscout.uk/why-we-left-github-for-gitlab/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-we-left-github-for-gitlab/</guid><description>&lt;img src="https://phpboyscout.uk/why-we-left-github-for-gitlab/cover-why-we-left-github-for-gitlab.png" alt="Featured image of post Why go-tool-base left GitHub for GitLab" /&gt;&lt;p&gt;A botched version bump made me stop and actually look at where go-tool-base lived, and I didn&amp;rsquo;t much like what I saw. GitHub had spent months quietly falling over, and when Mitchell Hashimoto (GitHub user #1299, no less) publicly walked Ghostty off the platform, it stopped feeling like just my problem. I&amp;rsquo;ve been a GitLab fan for years, so the move was less a leap and more an overdue nudge. This is the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="it-started-with-a-wrong-number"&gt;It started with a wrong number
&lt;/h2&gt;&lt;p&gt;Every migration has a trigger, and mine was embarrassingly small. A commit landed on &lt;code&gt;main&lt;/code&gt; carrying a &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; footer it didn&amp;rsquo;t really deserve. Semantic-release did exactly what it&amp;rsquo;s told to do with that footer: it cut a major version. go-tool-base lurched from the v1 line straight to v2.0.0, and a chain of things that keyed off the version went sideways with it.&lt;/p&gt;
&lt;p&gt;It was fixable. It wasn&amp;rsquo;t a disaster. But it was the kind of small, stupid breakage that makes you stop and actually &lt;em&gt;look&lt;/em&gt; at your setup instead of just patching it and moving on. And when I looked, the version bump wasn&amp;rsquo;t the thing that bothered me. It was everything around it.&lt;/p&gt;
&lt;h2 id="the-platform-had-been-quietly-failing"&gt;The platform had been quietly failing
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d been losing time to GitHub for months. Not dramatically. No single outage you&amp;rsquo;d write home about, just a steady drip of Actions queues that wouldn&amp;rsquo;t drain, pull requests that wouldn&amp;rsquo;t merge, the occasional morning where the thing simply wasn&amp;rsquo;t there. You absorb it. You re-run the job. You make a coffee and try again. You tell yourself it&amp;rsquo;s a blip.&lt;/p&gt;
&lt;p&gt;The trouble with a steady drip is that you stop counting it. It becomes weather.&lt;/p&gt;
&lt;h2 id="the-canary-left-the-mine"&gt;The canary left the mine
&lt;/h2&gt;&lt;p&gt;Then, in late April, &lt;a class="link" href="https://mitchellh.com/" target="_blank" rel="noopener"
 &gt;Mitchell Hashimoto&lt;/a&gt; (co-founder of HashiCorp, creator of Vagrant, Terraform and the Ghostty terminal) published &lt;a class="link" href="https://mitchellh.com/writing/ghostty-leaving-github" target="_blank" rel="noopener"
 &gt;&lt;em&gt;Ghostty Is Leaving GitHub&lt;/em&gt;&lt;/a&gt;, and &lt;em&gt;The Register&lt;/em&gt; &lt;a class="link" href="https://www.theregister.com/software/2026/04/29/mitchell-hashimoto-says-github-no-longer-for-serious-work/5227505" target="_blank" rel="noopener"
 &gt;picked it up&lt;/a&gt; a day later under the headline &amp;ldquo;GitHub &amp;rsquo;no longer a place for serious work&amp;rsquo;&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;This is not a man with a casual relationship to GitHub. He&amp;rsquo;s, by his own account, user #1299, joined February 2008. He called it &amp;ldquo;the place that has made me the most happy&amp;rdquo;. And he still wrote this:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;This is no longer a place for serious work if it just blocks you out for hours per day, every day.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;The detail that landed hardest for me wasn&amp;rsquo;t a quote, it was a habit. He&amp;rsquo;d kept a journal for a month, marking an &amp;ldquo;X&amp;rdquo; on every day a GitHub outage had cost him working time. &lt;em&gt;Almost every day had an X.&lt;/em&gt; Reading that, I realised I&amp;rsquo;d been having the same month. I&amp;rsquo;d just never been disciplined enough to write it down. He&amp;rsquo;d turned my vague &amp;ldquo;it&amp;rsquo;s been flaky lately&amp;rdquo; into a row of crosses on a calendar.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;I want to ship software and it doesn&amp;rsquo;t want me to ship software.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When the person who&amp;rsquo;s been on the platform for eighteen years and &lt;em&gt;loves&lt;/em&gt; it says that out loud, it stops being your private grumble. It&amp;rsquo;s the canary, and the canary has stopped singing.&lt;/p&gt;
&lt;h2 id="why-gitlab-and-not-just-somewhere-else"&gt;Why GitLab, and not just &amp;ldquo;somewhere else&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Being annoyed at GitHub is a reason to leave. It is not, on its own, a reason to pick a destination. The destination has to be a positive choice.&lt;/p&gt;
&lt;p&gt;For me GitLab was an easy one, because I&amp;rsquo;ve been a fan for years. Long enough, in fact, to have &lt;em&gt;also&lt;/em&gt; been a reliable grumbler about their pricing tiers, which is how you know it&amp;rsquo;s a real relationship and not a honeymoon. What I&amp;rsquo;ve always rated is the model: GitLab treats source hosting, CI/CD, the package registry, releases and Pages as &lt;em&gt;one integrated product&lt;/em&gt;, not a marketplace of bolted-on parts you assemble yourself.&lt;/p&gt;
&lt;p&gt;That integration is the actual prize. On the old setup, &amp;ldquo;CI&amp;rdquo; meant a folder of separate GitHub Actions workflow files, each pinned, each its own little world. On GitLab it&amp;rsquo;s a single &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/.gitlab-ci.yml#L7" target="_blank" rel="noopener"
 &gt;&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; pipeline&lt;/a&gt; with proper stages (lint, test, security, docs, release) and the release stage talks to the built-in package registry and Pages without me wiring up a single external credential. The CI job that builds the project can authenticate to the things the project needs &lt;em&gt;because they&amp;rsquo;re the same platform&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second-order benefit too. A migration is a rare licence to fix things you&amp;rsquo;d never otherwise touch. Moving gave me the cover to reset go-tool-base&amp;rsquo;s versioning cleanly (back to a sensible &lt;code&gt;v0.x&lt;/code&gt; line, the accidental &lt;code&gt;v2.0.0&lt;/code&gt; left behind as a cautionary tale) and to move the module path to its new home in one deliberate change rather than a thousand apologetic ones.&lt;/p&gt;
&lt;h2 id="what-im-not-going-to-claim"&gt;What I&amp;rsquo;m not going to claim
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not going to tell you GitHub is finished, or that GitLab never has a bad day, because it does, everyone does. This isn&amp;rsquo;t a teardown. GitHub gave go-tool-base a perfectly good home for its first year, and the archived mirror is still sitting there, read-only, pointing anyone who finds it at the new place.&lt;/p&gt;
&lt;p&gt;What changed is simpler than a grand verdict. The friction crossed a line, someone I respect said the quiet part loudly enough that I couldn&amp;rsquo;t keep filing it under &amp;ldquo;weather&amp;rdquo;, and the place I&amp;rsquo;d have moved to anyway was sitting right there with a better model. Sometimes the prudent move and the move you secretly wanted turn out to be the same move, and you just need a wrong version number to give you permission.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;go-tool-base moved from GitHub to GitLab in May 2026. The proximate cause was a self-inflicted version-bump mess; the real cause was months of GitHub unreliability that I&amp;rsquo;d stopped consciously noticing until Mitchell Hashimoto&amp;rsquo;s very public departure named it for me. GitLab was a positive pick, not just an escape hatch: its integrated CI/CD, registry, releases and Pages are one product rather than a kit, and that integration is genuinely worth having. The migration also bought a clean versioning restart as a bonus.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve been absorbing a steady drip of friction and telling yourself it&amp;rsquo;s normal: try the calendar trick. Mark the X&amp;rsquo;s for a month. The page will tell you something you already half-know.&lt;/p&gt;</description></item><item><title>Why I hand-rolled every module</title><link>https://phpboyscout.uk/why-i-hand-rolled-every-module/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-i-hand-rolled-every-module/</guid><description>&lt;img src="https://phpboyscout.uk/why-i-hand-rolled-every-module/cover-why-i-hand-rolled-every-module.png" alt="Featured image of post Why I hand-rolled every module" /&gt;&lt;p&gt;There are well-known community module libraries for AWS: Cloud Posse, the &lt;code&gt;terraform-aws-modules&lt;/code&gt; collection, plenty more. Both &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; use almost none of them. Every sub-module is hand-rolled from raw AWS resources, and before you accuse me of not-invented-here syndrome (a perfectly fair first guess), hear me out, because the same evaluation kept landing the same way for a real reason.&lt;/p&gt;
&lt;h2 id="the-promise-of-a-wrapper-module"&gt;The promise of a wrapper module
&lt;/h2&gt;&lt;p&gt;The community module ecosystem makes an appealing offer. Don&amp;rsquo;t write raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_policy&lt;/code&gt; and &lt;code&gt;aws_s3_bucket_public_access_block&lt;/code&gt; and the rest. Call a tested, popular module, pass it a handful of inputs, and get a correct, well-configured bucket. Less code in your repo, and the code you don&amp;rsquo;t write has been exercised by thousands of other users.&lt;/p&gt;
&lt;p&gt;For a lot of infrastructure that&amp;rsquo;s a genuinely good deal, and I take it often. For the two infrastructure modules in this series, I took it almost never. Every sub-module is built from raw AWS resources. That wasn&amp;rsquo;t a reflex. It was the same evaluation, made over and over, landing the same way.&lt;/p&gt;
&lt;h2 id="what-kept-going-wrong"&gt;What kept going wrong
&lt;/h2&gt;&lt;p&gt;For each place a wrapper module could have fitted, I looked at the wrapper. And the recurring finding was one of two things. Either using the wrapper &lt;em&gt;correctly&lt;/em&gt;, with all the overrides my posture needed, came to more configuration than the raw resources would have. Or the wrapper&amp;rsquo;s abstraction leaked the instant I needed something it hadn&amp;rsquo;t anticipated, and I was now writing code to fight it.&lt;/p&gt;
&lt;h2 id="the-cloudtrail-bucket-concretely"&gt;The CloudTrail bucket, concretely
&lt;/h2&gt;&lt;p&gt;The clearest example is the bucket that holds CloudTrail logs.&lt;/p&gt;
&lt;p&gt;There are popular modules that set up CloudTrail and bundle an S3 bucket for the logs. Convenient. But that bundled bucket isn&amp;rsquo;t the bucket I want. It doesn&amp;rsquo;t carry &lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;, and its bucket policy is weaker than the one &lt;a class="link" href="https://phpboyscout.uk/a-state-bucket-that-defends-itself/" &gt;the state bucket&lt;/a&gt; taught me to want: TLS-only, SSE-KMS-only, wrong-key-denied.&lt;/p&gt;
&lt;p&gt;So to use the wrapper I had two options. Accept a weaker audit-log bucket than the rest of the account, which rather defeats the point of an audit log. Or fight the wrapper: disable its bucket, create my own, wire it back in. Fighting the wrapper is &lt;em&gt;more&lt;/em&gt; work than simply writing the fifty-odd lines of raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; plus policy that give me exactly the posture I&amp;rsquo;d already designed once. The wrapper didn&amp;rsquo;t save code. It added a negotiation.&lt;/p&gt;
&lt;h2 id="a-wrapper-is-a-deal-and-deals-have-terms"&gt;A wrapper is a deal, and deals have terms
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t an argument that community modules are bad. It&amp;rsquo;s an argument about when the deal is good.&lt;/p&gt;
&lt;p&gt;A wrapper module is a good deal while its abstraction holds: while what it assumes you want matches what you want. The moment you need something it didn&amp;rsquo;t anticipate, the deal inverts. Now you&amp;rsquo;re working &lt;em&gt;against&lt;/em&gt; the abstraction, and an abstraction you&amp;rsquo;re fighting costs more than no abstraction at all. (Regular readers will recognise that line from &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the LangChain argument&lt;/a&gt;; it&amp;rsquo;s the same principle in a very different language.)&lt;/p&gt;
&lt;p&gt;Infrastructure that holds signing keys is precisely the case where you need to control the specifics: every encryption setting, every lifecycle rule, every line of every bucket policy. That&amp;rsquo;s a domain where wrapper abstractions leak fast, because the whole job &lt;em&gt;is&lt;/em&gt; the details the wrapper smoothed over.&lt;/p&gt;
&lt;h2 id="the-cost-paid-on-purpose"&gt;The cost, paid on purpose
&lt;/h2&gt;&lt;p&gt;Hand-rolling isn&amp;rsquo;t free. It&amp;rsquo;s more lines of HCL in the repo, up front, than a one-line module call.&lt;/p&gt;
&lt;p&gt;What those lines buy is worth the price &lt;em&gt;for this kind of infrastructure&lt;/em&gt;. There&amp;rsquo;s no transitive module-version churn to track. There&amp;rsquo;s no abstraction between me and the resource when something behaves oddly. And every line is one I can read, and defend, in a security review, because I wrote it and it says exactly what it does. For a foundation that will hold the most sensitive key in the system, &amp;ldquo;readable and mine&amp;rdquo; beats &amp;ldquo;short and someone else&amp;rsquo;s&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate trade, not a universal rule. For an internal tool on a deadline, reach for the wrapper. For the security-critical base of everything else, the raw resources won every time I checked.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;The community module ecosystem offers less code that more people have tested, and for plenty of infrastructure that&amp;rsquo;s the right call. For &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; and &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; it almost never was, because each wrapper turned out to be more configuration than the raw resources once my posture was accounted for, or it leaked the moment I needed a specific.&lt;/p&gt;
&lt;p&gt;The CloudTrail log bucket is the pattern in miniature: the bundled bucket lacked &lt;code&gt;prevent_destroy&lt;/code&gt; and a strong policy, so using the wrapper meant either a weaker bucket or fighting the module. A wrapper is a good deal while its abstraction holds and a bad one the moment you fight it, and security-critical foundation infrastructure is all specifics. Hand-rolling cost more lines and bought code I can read and defend. For this, that was the trade worth making.&lt;/p&gt;</description></item><item><title>Hardening the account that will hold the keys</title><link>https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/</link><pubDate>Sat, 09 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/</guid><description>&lt;img src="https://phpboyscout.uk/hardening-the-account-that-will-hold-the-keys/cover-hardening-the-account-that-will-hold-the-keys.png" alt="Featured image of post Hardening the account that will hold the keys" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;Bootstrapping the account&lt;/a&gt; got it &lt;em&gt;ready&lt;/em&gt;: somewhere to store state, an identity to deploy as, enough for the next &lt;code&gt;tofu apply&lt;/code&gt; to run. Ready is not the same as safe. An account with no audit trail, nothing watching it, and no considered way for a human to get in is fine for experimenting and absolutely not where you put the most sensitive key in the system. So before the signing key goes anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="ready-is-not-the-same-as-safe"&gt;Ready is not the same as safe
&lt;/h2&gt;&lt;p&gt;The bootstrap post ended with an account that was &lt;em&gt;ready&lt;/em&gt;: it had somewhere to store state and a CI identity to deploy as. The next &lt;code&gt;tofu apply&lt;/code&gt; could run.&lt;/p&gt;
&lt;p&gt;Ready is not safe. That account still has no audit trail, so nobody could tell you afterwards what happened in it. It has no threat detection, so nothing is watching. Its defaults are AWS&amp;rsquo;s defaults, which are not a security posture. There&amp;rsquo;s no considered way for a human to get in. An account in that condition is fine for experimenting. It&amp;rsquo;s not somewhere you put the most sensitive key in the whole system.&lt;/p&gt;
&lt;p&gt;So before the signing key is anywhere near it, the account gets a security baseline.&lt;/p&gt;
&lt;h2 id="the-baseline-in-one-downstream-stack"&gt;The baseline, in one downstream stack
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; is that baseline, and it&amp;rsquo;s exactly the downstream stack the bootstrap post promised: applied &lt;em&gt;through&lt;/em&gt; the automation role bootstrap created, not bootstrapped specially.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s six sub-modules, each behind an &lt;code&gt;enable_*&lt;/code&gt; toggle: &lt;code&gt;account-hardening&lt;/code&gt; (IAM password policy, account-wide S3 public-access blocking, default EBS encryption), &lt;code&gt;audit-logging&lt;/code&gt; (a multi-region CloudTrail with log-file validation), &lt;code&gt;aws-config&lt;/code&gt;, &lt;code&gt;threat-detection&lt;/code&gt; (GuardDuty, Security Hub, IAM Access Analyzer), &lt;code&gt;alerts&lt;/code&gt;, and &lt;code&gt;operator-role&lt;/code&gt;. Together they turn a bare account into one that records what happens, watches for trouble, and controls who gets in.&lt;/p&gt;
&lt;p&gt;Most of those are the expected baseline. The operator role is the one worth slowing down on, because it&amp;rsquo;s built backwards from how people usually think about an admin role.&lt;/p&gt;
&lt;h2 id="the-operator-role-and-the-inversion"&gt;The operator role, and the inversion
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/tree/v0.2.0/modules/operator-role" target="_blank" rel="noopener"
 &gt;&lt;code&gt;InfraAdmin&lt;/code&gt;&lt;/a&gt; is the human way into the account: the role a person assumes to do operator work. Two things define it.&lt;/p&gt;
&lt;p&gt;The trust policy decides &lt;em&gt;who&lt;/em&gt; may assume it. It trusts only the account root principal, and it requires multi-factor authentication: the assume call must carry &lt;code&gt;aws:MultiFactorAuthPresent&lt;/code&gt;, and &lt;code&gt;aws:MultiFactorAuthAge&lt;/code&gt; bounds how recently that MFA was performed. No MFA, no role. So far this is a careful but ordinary admin role.&lt;/p&gt;
&lt;p&gt;The inversion is a &lt;em&gt;second&lt;/em&gt;, separate inline policy, and it&amp;rsquo;s almost entirely &lt;code&gt;Deny&lt;/code&gt;. It denies, using &lt;code&gt;NotAction&lt;/code&gt;, anything where &lt;code&gt;aws:RequestedRegion&lt;/code&gt; falls outside an allowed set of regions. The role&amp;rsquo;s &lt;em&gt;power&lt;/em&gt; comes from an admin grant. This inline policy &lt;em&gt;fences&lt;/em&gt; that power.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the part worth holding onto. People picture an admin role as a list of what it can do. This one is better understood by what it &lt;em&gt;cannot&lt;/em&gt;: it cannot act outside its permitted regions, full stop. A fat-fingered command, or a compromised session, cannot quietly spin resources up in some region nobody&amp;rsquo;s watching. The fence is as much the point of the role as the grant is.&lt;/p&gt;
&lt;h2 id="the-carve-out-because-honesty"&gt;The carve-out, because honesty
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a fiddly detail, and it&amp;rsquo;s the kind of thing that makes the region fence real rather than theoretical.&lt;/p&gt;
&lt;p&gt;Some AWS services are global. IAM, CloudFront, Route 53 and friends have no region, and they don&amp;rsquo;t honour &lt;code&gt;aws:RequestedRegion&lt;/code&gt;. A naive region-deny would therefore deny calls to IAM, and you&amp;rsquo;d lock yourself out of the very service you manage access with. (A close cousin of the kind of self-inflicted lockout I&amp;rsquo;ll come back to in a &lt;a class="link" href="https://phpboyscout.uk/a-403-you-cant-fix-in-iam/" &gt;later post&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;So the Deny carries explicit carve-outs for the global services. It isn&amp;rsquo;t elegant, and it can&amp;rsquo;t be: the global-versus-regional split is just a fact of AWS, and a correct region fence has to account for it. The carve-out list is the real cost of the control working.&lt;/p&gt;
&lt;h2 id="harden-the-room-then-move-the-keys-in"&gt;Harden the room, then move the keys in
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an order to all of this, and the order is the argument.&lt;/p&gt;
&lt;p&gt;The account that will hold the signing key has to be audited before the key arrives, so that from day one every call against it is in CloudTrail. It has to be watched before the key arrives, so GuardDuty is already looking. It has to be access-controlled before the key arrives, so the only human path in is MFA-gated and region-fenced.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t move something valuable into a room and then think about locks. You build the room, fit the locks, check they work, and &lt;em&gt;then&lt;/em&gt; move the valuable thing in. The security baseline is fitting the locks. The signing key comes later, into a room already built for it.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Bootstrapping an account makes it ready for the next deploy. It does not make it safe to hold anything that matters. &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt; is the downstream stack that closes that gap: audit logging, AWS Config, threat detection, account hardening, and an operator role, applied through the CI role bootstrap created.&lt;/p&gt;
&lt;p&gt;The operator role is the piece to study. It&amp;rsquo;s MFA-gated on the way in, and then fenced by a separate, almost-all-&lt;code&gt;Deny&lt;/code&gt; inline policy that confines it to permitted regions, with carve-outs for the global services that have no region. An admin role defined as much by its fence as its grant. Harden the room first; the keys move in afterwards.&lt;/p&gt;</description></item><item><title>No access keys in CI</title><link>https://phpboyscout.uk/no-access-keys-in-ci/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/no-access-keys-in-ci/</guid><description>&lt;img src="https://phpboyscout.uk/no-access-keys-in-ci/cover-no-access-keys-in-ci.png" alt="Featured image of post No access keys in CI" /&gt;&lt;p&gt;A long-lived AWS access key, sitting in a CI system, is just about the single credential I&amp;rsquo;d most like to be rid of. It&amp;rsquo;s powerful, it never expires unless someone remembers to rotate it (nobody remembers to rotate it), and it lives in one of the most attractive targets in the whole supply chain. For infrastructure that&amp;rsquo;s eventually going to hold a release-signing key, it&amp;rsquo;s exactly the wrong place to start. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. None.&lt;/p&gt;
&lt;h2 id="the-access-key-you-dont-want"&gt;The access key you don&amp;rsquo;t want
&lt;/h2&gt;&lt;p&gt;A CI pipeline that runs &lt;code&gt;tofu apply&lt;/code&gt; against AWS needs AWS credentials. The traditional way to give it some is an IAM user with an access key pair, pasted into the CI system as a masked variable.&lt;/p&gt;
&lt;p&gt;Look at what that key is. It&amp;rsquo;s long-lived: it works until someone remembers to rotate it, and rotating it is a chore, so mostly nobody does. It&amp;rsquo;s powerful: it can apply infrastructure, so it can do nearly anything. And it&amp;rsquo;s sitting in a CI system, which is one of the most attractive targets in your whole supply chain. You&amp;rsquo;ve taken your highest-value credential and stored a permanent copy of it in a place built for running automated jobs.&lt;/p&gt;
&lt;p&gt;For infrastructure that&amp;rsquo;s going to hold a release-signing key, that&amp;rsquo;s precisely the wrong starting point. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. Not a well-guarded one. None.&lt;/p&gt;
&lt;h2 id="federation-instead-of-a-stored-secret"&gt;Federation instead of a stored secret
&lt;/h2&gt;&lt;p&gt;The replacement is OIDC federation, and the shape of it is worth walking through, because it&amp;rsquo;s genuinely different from &amp;ldquo;a secret, but better&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;A modern CI platform can mint an OIDC token. GitLab does this with an &lt;code&gt;id_tokens:&lt;/code&gt; block: at job time, GitLab issues a short-lived JSON Web Token, signed by GitLab, that asserts a set of facts. This is project X. This is pipeline Y. This is running on ref Z, of this type.&lt;/p&gt;
&lt;p&gt;AWS can consume that. The &lt;code&gt;sts:AssumeRoleWithWebIdentity&lt;/code&gt; call takes such a token and, if it satisfies an IAM role&amp;rsquo;s trust policy, returns short-lived AWS credentials for that role. The trust policy is where the control lives: it names GitLab as a trusted token issuer, and it constrains the token&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt; claim so that only the specific project, and the specific refs, you intend can assume the role.&lt;/p&gt;
&lt;p&gt;Put it together: the pipeline asks GitLab for a token, hands it to AWS, and gets back credentials that last about an hour and are scoped to one role. Nothing long-lived is stored anywhere. The credential exists only for the job that needs it, and it can&amp;rsquo;t be stolen from a CI variable store, because it was never in one.&lt;/p&gt;
&lt;h2 id="two-halves-of-one-handshake"&gt;Two halves of one handshake
&lt;/h2&gt;&lt;p&gt;That handshake is built by two of the repos in this series, each owning one side.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt; builds the AWS half, in its &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/automation-iam/main.tf#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;automation-iam&lt;/code&gt; module&lt;/a&gt;: it registers GitLab as an OIDC identity provider in the account, and it creates the automation role with the trust policy that decides which pipelines may assume it.&lt;/p&gt;
&lt;p&gt;The CI components build the consuming half: the &lt;code&gt;id_tokens:&lt;/code&gt; block that asks GitLab for the JWT, and then simply letting the AWS provider&amp;rsquo;s own credential chain perform the exchange. The pipeline doesn&amp;rsquo;t call &lt;code&gt;sts&lt;/code&gt; by hand. It presents the token; the SDK does the rest.&lt;/p&gt;
&lt;h2 id="the-gotcha-dont-set-a-profile"&gt;The gotcha: don&amp;rsquo;t set a profile
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one quiet way to break this, and a stack can look completely correct while doing it.&lt;/p&gt;
&lt;p&gt;The AWS SDK finds credentials by walking a chain of sources in order. The web-identity path, the one that uses the OIDC token, is one link in that chain. It triggers off environment variables the CI sets up automatically.&lt;/p&gt;
&lt;p&gt;But if the &lt;code&gt;aws&lt;/code&gt; provider block has a hardcoded &lt;code&gt;profile = &amp;quot;...&amp;quot;&lt;/code&gt;, the SDK takes the &lt;em&gt;profile&lt;/em&gt; link of the chain instead, and never reaches the web-identity link. A &lt;code&gt;profile&lt;/code&gt; line is the sort of thing that ends up in a provider block from someone&amp;rsquo;s local development setup, where it&amp;rsquo;s exactly right. Committed and run in CI, it silently short-circuits the federation. The pipeline either fails to find credentials, or finds the wrong ones.&lt;/p&gt;
&lt;p&gt;The rule is simple once you know it: the provider block that runs in CI must not name a &lt;code&gt;profile&lt;/code&gt;. Leave the chain free to find the web identity. It&amp;rsquo;s the kind of bug that teaches you to be precise about &lt;em&gt;which&lt;/em&gt; link of the credential chain you&amp;rsquo;re actually relying on.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Giving CI an AWS access key means storing your most powerful, longest-lived credential in one of your most exposed systems. OIDC federation removes it entirely. The CI platform mints a short-lived signed token, AWS exchanges it via &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; for hour-long credentials against a role whose trust policy names the exact pipeline, and nothing permanent is stored.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; builds the AWS side, the identity provider and the trust policy; the CI components build the consuming side, the token request. The one trap is a hardcoded &lt;code&gt;profile&lt;/code&gt; in the provider block, which short-circuits the SDK&amp;rsquo;s credential chain before it reaches the web-identity path. Get that right, and a pipeline deploys to AWS as a verifiable, short-lived identity, with no key to steal.&lt;/p&gt;</description></item><item><title>Two layers of tags, and which one wins</title><link>https://phpboyscout.uk/two-layers-of-tags/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-layers-of-tags/</guid><description>&lt;img src="https://phpboyscout.uk/two-layers-of-tags/cover-two-layers-of-tags.png" alt="Featured image of post Two layers of tags, and which one wins" /&gt;&lt;p&gt;Tagging cloud resources is one of those jobs that&amp;rsquo;s trivial to do badly and surprisingly fiddly to do well. Everyone agrees resources should be tagged. The argument nobody quite has out loud is &lt;em&gt;where the tags should come from&lt;/em&gt;, and getting that wrong gives you either a giant copy-pasted tag block on every resource, or a set of tags that quietly disagree with each other across the account.&lt;/p&gt;
&lt;h2 id="tags-answer-two-different-questions"&gt;Tags answer two different questions
&lt;/h2&gt;&lt;p&gt;If you look at what tags are actually &lt;em&gt;for&lt;/em&gt;, they split cleanly into two kinds, and the split is the whole point.&lt;/p&gt;
&lt;p&gt;Some tags are true of every single resource in the account, identically. The environment it belongs to. The fact that OpenTofu manages it. The project or owner it rolls up to for cost reporting. These are invariants: a resource that didn&amp;rsquo;t carry them would be the bug.&lt;/p&gt;
&lt;p&gt;Other tags are specific to a particular piece of infrastructure. Which component this resource belongs to, what subsystem it&amp;rsquo;s part of. The CloudTrail bucket is part of audit logging; the Config recorder is part of &lt;code&gt;aws-config&lt;/code&gt;. That&amp;rsquo;s a fact about the module, not about the account.&lt;/p&gt;
&lt;p&gt;Treat those two kinds the same and you end up repeating the invariants by hand on every resource, which is exactly the copy-paste that drifts. So the &lt;code&gt;infra&lt;/code&gt; setup gives each kind its own home.&lt;/p&gt;
&lt;h2 id="layer-one-declared-once-on-the-provider"&gt;Layer one: declared once, on the provider
&lt;/h2&gt;&lt;p&gt;The invariants live on the AWS provider itself, as &lt;code&gt;default_tags&lt;/code&gt;, set one time in the provider block:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws&amp;#34;&lt;/span&gt; {&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;default_tags&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; {&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # Environment, project, managed-by: the things true of
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # every resource in this account.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;default_tags&lt;/code&gt; applies those tags to every taggable resource the provider creates, automatically, without a single resource having to mention them. Change the environment label once, here, and it propagates to everything on the next apply. No resource carries a copy; they all inherit the originals. The invariants are stated exactly once, in the one place that&amp;rsquo;s true for all of them.&lt;/p&gt;
&lt;h2 id="layer-two-merged-in-by-the-module"&gt;Layer two: merged in by the module
&lt;/h2&gt;&lt;p&gt;The resource-specific tags live where the resource does: inside the module. Each module merges its own component tag over whatever tags it was handed, which you can see in the public &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-security-baseline/-/blob/v0.2.0/modules/aws-config/main.tf#L10" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;&lt;/a&gt; modules:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt; merge({ Component&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws-config&amp;#34;&lt;/span&gt; }&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So the &lt;code&gt;aws-config&lt;/code&gt; module stamps &lt;code&gt;Component = &amp;quot;aws-config&amp;quot;&lt;/code&gt; onto the things it builds, the &lt;code&gt;account-hardening&lt;/code&gt; module stamps its own, and so on. The caller can pass extra tags down through &lt;code&gt;var.tags&lt;/code&gt;, and because they come last in the &lt;code&gt;merge&lt;/code&gt;, the caller can override the module&amp;rsquo;s defaults when it genuinely needs to. Module-specific knowledge stays in the module; per-call adjustments stay with the caller.&lt;/p&gt;
&lt;h2 id="which-layer-wins"&gt;Which layer wins
&lt;/h2&gt;&lt;p&gt;Now the question that actually bites: a resource is getting tags from the provider&amp;rsquo;s &lt;code&gt;default_tags&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; from the module&amp;rsquo;s &lt;code&gt;merge&lt;/code&gt;. What happens when both set the same key?&lt;/p&gt;
&lt;p&gt;The resource-level tags win. AWS&amp;rsquo;s provider treats tags set directly on a resource as an override of &lt;code&gt;default_tags&lt;/code&gt; on a key collision, so the module&amp;rsquo;s merged tags take precedence over the account-wide defaults. That&amp;rsquo;s the right way round: the invariants are a sensible baseline, and a module that has a specific reason to set a key differently can, without having to reach up and edit the provider block that everything else depends on. Most of the time the two layers are simply disjoint, the invariants saying &lt;em&gt;what account this is&lt;/em&gt; and the module tags saying &lt;em&gt;what this resource is for&lt;/em&gt;, and they never collide at all. When they do, local intent beats the global default, which is the precedence you&amp;rsquo;d want.&lt;/p&gt;
&lt;h2 id="why-bother-splitting-it"&gt;Why bother splitting it
&lt;/h2&gt;&lt;p&gt;The payoff is that neither layer has to know about the other. The provider declares the invariants once and never thinks about components. Each module declares its component and never hard-codes the environment. Add a new module and it inherits every account-wide tag for free, while contributing its own. Change an account-wide tag and you touch one block, not two hundred resources. The tags stay consistent not because someone&amp;rsquo;s policing them, but because the place each tag is declared is the one place it &lt;em&gt;can&lt;/em&gt; be declared.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;Resource tags answer two questions, and they want two homes. Account-wide invariants (environment, ownership, managed-by) go on the provider&amp;rsquo;s &lt;code&gt;default_tags&lt;/code&gt;, declared once and inherited by everything. Resource-specific tags go in the module, via &lt;code&gt;merge({ Component = &amp;quot;...&amp;quot; }, var.tags)&lt;/code&gt;, so each module owns its own labels and the caller can still override. On a key conflict the resource-level tag wins, which means the module&amp;rsquo;s intent beats the account default exactly when it should. Two layers, each declared in the one place it belongs, and no copy-pasted tag block anywhere in sight.&lt;/p&gt;</description></item><item><title>clap's global flag, except in a passthrough subtree</title><link>https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/</guid><description>&lt;img src="https://phpboyscout.uk/claps-global-flag-except-in-a-passthrough-subtree/cover-claps-global-flag-except-in-a-passthrough-subtree.png" alt="Featured image of post clap's global flag, except in a passthrough subtree" /&gt;&lt;p&gt;&lt;code&gt;--output json&lt;/code&gt; worked everywhere. On the top-level command, on every ordinary subcommand, wherever the user fancied putting it. Then it stopped working in exactly one place, and of course it was the subcommand I&amp;rsquo;d been clever about.&lt;/p&gt;
&lt;h2 id="how-the-global-flag-is-meant-to-work"&gt;How the global flag is meant to work
&lt;/h2&gt;&lt;p&gt;clap has a lovely feature for this. Define &lt;code&gt;--output text|json&lt;/code&gt; once at the top, mark it &lt;code&gt;global = true&lt;/code&gt;, and it&amp;rsquo;s reachable from every subcommand: &lt;code&gt;mytool --output json widget&lt;/code&gt; and &lt;code&gt;mytool widget --output json&lt;/code&gt; land the same. You stop thinking about it.&lt;/p&gt;
&lt;h2 id="the-one-place-it-goes-missing"&gt;The one place it goes missing
&lt;/h2&gt;&lt;p&gt;One subcommand, &lt;code&gt;credentials&lt;/code&gt;, is a passthrough: it sets &lt;code&gt;subcommand_passthrough = true&lt;/code&gt;, which makes clap capture everything after the subcommand name as &lt;code&gt;trailing_var_arg&lt;/code&gt; and hand it on, the way &lt;code&gt;cargo run -- ...&lt;/code&gt; passes the trailing args to your program rather than to cargo. The handler then re-parses those captured tokens against its own clap definition.&lt;/p&gt;
&lt;p&gt;The trouble is that the captured tokens include &lt;code&gt;--output&lt;/code&gt;. clap&amp;rsquo;s &lt;code&gt;global = true&lt;/code&gt; propagation doesn&amp;rsquo;t reach a passthrough subtree, because the post-name tokens are taken as &lt;code&gt;trailing_var_arg&lt;/code&gt; before the outer parser ever sees them. So in this one subtree the global flag isn&amp;rsquo;t applied, and worse, when the inner parser re-parses the captured args it meets &lt;code&gt;--output&lt;/code&gt;, which it doesn&amp;rsquo;t define, and rejects it as unknown. The code says so where it matters, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-cli/src/credentials.rs#L62-69" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-cli/src/credentials.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// clap&amp;#39;s outer `global = true` propagation works for normal
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// subcommands, but `subcommand_passthrough = true` captures
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// post-name tokens as `trailing_var_arg`, so the global
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// never reaches the outer parser for this subtree.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;strip_global_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="parse-it-yourself-then-strip-it"&gt;Parse it yourself, then strip it
&lt;/h2&gt;&lt;p&gt;The fix is two moves. First, parse &lt;code&gt;--output&lt;/code&gt; out of the raw args by hand (there&amp;rsquo;s an &lt;code&gt;OutputMode::from_args_os&lt;/code&gt; for exactly that), so the output mode is still honoured. Then strip &lt;code&gt;--output&lt;/code&gt; out of the args before the inner parser runs, so the inner clap doesn&amp;rsquo;t choke on a flag it doesn&amp;rsquo;t define. &lt;code&gt;strip_global_output&lt;/code&gt; is the second move, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-cli/src/render.rs#L95" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-cli/src/render.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;--output=&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// inline form: drop just this token
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;--output&amp;#34;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// space-separated form: drop the token and its value
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It handles both &lt;code&gt;--output=json&lt;/code&gt; and &lt;code&gt;--output json&lt;/code&gt;, and it&amp;rsquo;s idempotent, so it&amp;rsquo;s safe to call whether or not the flag is actually present.&lt;/p&gt;
&lt;h2 id="the-takeaway"&gt;The takeaway
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;global = true&lt;/code&gt; and &lt;code&gt;trailing_var_arg&lt;/code&gt; are both &amp;ldquo;grab the args&amp;rdquo; features, and in a passthrough subcommand they reach for the same tokens. clap won&amp;rsquo;t arbitrate that overlap, and shouldn&amp;rsquo;t try to guess. So you arbitrate: parse the global out of the raw args yourself, strip it before you re-parse the rest, and the flag that &amp;ldquo;works everywhere&amp;rdquo; actually does.&lt;/p&gt;</description></item><item><title>Secrets that scrub themselves from RAM</title><link>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/</guid><description>&lt;img src="https://phpboyscout.uk/secrets-that-scrub-themselves-from-ram/cover-secrets-that-scrub-themselves-from-ram.png" alt="Featured image of post Secrets that scrub themselves from RAM" /&gt;&lt;p&gt;A while ago I worked out &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI should keep your API key&lt;/a&gt;: env var, OS keychain, or, grudgingly, a literal in the config file. That answers where the secret &lt;em&gt;lives&lt;/em&gt;. It says nothing about what happens to it once it&amp;rsquo;s loaded and sitting in your process memory, which is the half where secrets actually tend to leak. Rust, it turns out, can do something about that half that Go simply can&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-already-settled"&gt;What go-tool-base already settled
&lt;/h2&gt;&lt;p&gt;A while back I wrote about where a CLI should keep your API keys. The answer go-tool-base settled on was three storage modes, in a fixed precedence: an environment variable reference (the recommended default), the OS keychain (opt-in), or a literal value in the config file (legacy, and refused outright when &lt;code&gt;CI=true&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps that design unchanged. Same three modes, same precedence, same refusal of literal secrets in CI. A tool embeds a &lt;code&gt;CredentialRef&lt;/code&gt; in its typed config, and a &lt;code&gt;Resolver&lt;/code&gt; walks env, then keychain, then literal, then a well-known fallback variable, first hit wins. That part is a straight carry-over, because &lt;em&gt;where&lt;/em&gt; to keep the secret was design, and design survives the port.&lt;/p&gt;
&lt;p&gt;But storage is only half the life of a secret. The other half is what happens to it once it&amp;rsquo;s resolved and sitting in your process memory. That&amp;rsquo;s where Rust can do something Go can&amp;rsquo;t, and rust-tool-base takes the opening.&lt;/p&gt;
&lt;h2 id="the-two-ways-a-secret-leaks-after-youve-loaded-it"&gt;The two ways a secret leaks after you&amp;rsquo;ve loaded it
&lt;/h2&gt;&lt;p&gt;You&amp;rsquo;ve resolved the API key. It&amp;rsquo;s a value in memory now. Two very ordinary things can leak it from there, and neither involves your storage being wrong.&lt;/p&gt;
&lt;p&gt;The first is &lt;strong&gt;the log line&lt;/strong&gt;. Somewhere a developer writes a debug print of a config struct, or an error includes the struct that holds the key, or a panic dumps it. The secret is a string like any other string, so it renders like any other string, straight into a log aggregator that a lot of people can read.&lt;/p&gt;
&lt;p&gt;The second is &lt;strong&gt;the leftover bytes&lt;/strong&gt;. The key sat in a heap allocation. The variable goes out of scope, the allocation is freed, and on most runtimes &amp;ldquo;freed&amp;rdquo; just means &amp;ldquo;returned to the allocator&amp;rdquo;. The bytes are still there until something else writes over them. A core dump taken in that window contains your key. So does the next allocation that happens to land on that memory and gets logged before it&amp;rsquo;s overwritten.&lt;/p&gt;
&lt;p&gt;A Go string can&amp;rsquo;t really defend against either. Go strings are immutable, so you can&amp;rsquo;t zero one in place; the runtime copies them freely, so you can&amp;rsquo;t even track every copy; and there&amp;rsquo;s no compile-time barrier stopping anyone printing one. You can be disciplined, but discipline is all you&amp;rsquo;ve got.&lt;/p&gt;
&lt;h2 id="secretstring-closes-both"&gt;&lt;code&gt;SecretString&lt;/code&gt; closes both
&lt;/h2&gt;&lt;p&gt;rust-tool-base routes every secret through &lt;code&gt;secrecy::SecretString&lt;/code&gt;, and the crate is explicit that taking a plain &lt;code&gt;&amp;amp;str&lt;/code&gt; or &lt;code&gt;String&lt;/code&gt; for a secret is a &lt;em&gt;type error&lt;/em&gt;, not a style preference.&lt;/p&gt;
&lt;p&gt;For the log line, &lt;code&gt;SecretString&lt;/code&gt; has its own &lt;code&gt;Debug&lt;/code&gt; implementation, and it prints &lt;code&gt;[REDACTED]&lt;/code&gt;. Always. A config struct holding a &lt;code&gt;SecretString&lt;/code&gt; can be debug-printed, put in an error, caught in a panic, and the secret field shows up as &lt;code&gt;[REDACTED]&lt;/code&gt; every single time. You don&amp;rsquo;t have to remember not to log it. The type already won&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;For the leftover bytes, &lt;code&gt;SecretString&lt;/code&gt; zeroes its memory when it&amp;rsquo;s dropped. When the value goes out of scope, before the allocation is handed back, the bytes are overwritten. The window where a freed allocation still holds your key is closed. A core dump taken afterwards finds zeroes.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a third leak &lt;code&gt;SecretString&lt;/code&gt; blocks that&amp;rsquo;s easy to miss. It deliberately doesn&amp;rsquo;t implement &lt;code&gt;Serialize&lt;/code&gt;. You cannot serialise a &lt;code&gt;SecretString&lt;/code&gt;. That sounds like an inconvenience until you see what it prevents: a tool that loads config, changes one setting, and writes the whole struct back would, with an ordinary string, faithfully write the resolved secret to disk in plain text. Because &lt;code&gt;SecretString&lt;/code&gt; can&amp;rsquo;t be serialised, &lt;code&gt;CredentialRef&lt;/code&gt; can&amp;rsquo;t be either, and that accident is structurally impossible. Writing a secret back is a deliberate, separate path, never a side effect of saving config.&lt;/p&gt;
&lt;p&gt;When code genuinely needs the raw value, to drop it into an &lt;code&gt;Authorization&lt;/code&gt; header, it calls &lt;code&gt;expose_secret()&lt;/code&gt;. The name is the point. Getting at the plaintext is one explicit, greppable, reviewable call, and everywhere else the secret stays wrapped.&lt;/p&gt;
&lt;h2 id="discipline-versus-the-type-system"&gt;Discipline versus the type system
&lt;/h2&gt;&lt;p&gt;The plain framing is this. None of these leaks are exotic. Logging a struct, a core dump after a free, re-saving a config file: they&amp;rsquo;re all routine, and they&amp;rsquo;re all how real credentials end up somewhere they shouldn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s storage design is good, and rust-tool-base kept it. But in Go, &lt;em&gt;not leaking the secret once it&amp;rsquo;s in memory&lt;/em&gt; comes down to every developer being careful every time. In Rust, &lt;code&gt;SecretString&lt;/code&gt; makes &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;the type system&lt;/a&gt; carry it. The redaction, the zeroing, the un-serialisability aren&amp;rsquo;t things you remember to do. They&amp;rsquo;re things the secret does to itself because of what it is. That&amp;rsquo;s the part Go structurally can&amp;rsquo;t match, and it&amp;rsquo;s why the port didn&amp;rsquo;t just copy the storage modes across, it tightened the handling underneath them.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;go-tool-base settled where a CLI keeps a secret: env var, keychain, or literal, in a fixed precedence. rust-tool-base keeps that design and hardens what happens once the secret is loaded.&lt;/p&gt;
&lt;p&gt;Every secret is a &lt;code&gt;secrecy::SecretString&lt;/code&gt;. It debug-prints as &lt;code&gt;[REDACTED]&lt;/code&gt;, so it can&amp;rsquo;t fall into a log by accident. Its memory is zeroed on drop, so it doesn&amp;rsquo;t survive in freed heap. It isn&amp;rsquo;t serialisable, so it can&amp;rsquo;t be written back to config by a blanket save. Getting the plaintext is one explicit &lt;code&gt;expose_secret()&lt;/code&gt; call. Go can only ask developers to be careful with a secret in memory; Rust lets the type be careful for them.&lt;/p&gt;</description></item><item><title>The chicken-and-egg of remote state</title><link>https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/</guid><description>&lt;img src="https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/cover-the-chicken-and-egg-of-remote-state.png" alt="Featured image of post The chicken-and-egg of remote state" /&gt;&lt;p&gt;Here&amp;rsquo;s a puzzle that every infrastructure-as-code setup hits exactly once, right at the very beginning, and then never again. An OpenTofu stack stores its state in a backend. &lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;The bootstrap stack&lt;/a&gt; I wrote about last time has a particular job, and part of that job is to &lt;em&gt;create&lt;/em&gt; the backend that remote state lives in. So where does the bootstrap stack store its own state, on the very first run, before it&amp;rsquo;s built the place state is supposed to go?&lt;/p&gt;
&lt;h2 id="where-does-the-state-of-the-thing-that-makes-the-state-store-live"&gt;Where does the state of the thing that makes the state store live?
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the puzzle, and it&amp;rsquo;s a real ordering deadlock rather than a riddle.&lt;/p&gt;
&lt;p&gt;An OpenTofu stack keeps a state file, and for anything shared that state file lives in a remote backend: on AWS, an S3 bucket. Fine. But the bootstrap stack has a particular job, and part of that job is to &lt;em&gt;create the S3 bucket that remote state lives in&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;So walk through the first run. Bootstrap has never been applied. The state bucket doesn&amp;rsquo;t exist, because creating it is what bootstrap is for. Bootstrap needs somewhere to store its own state. The only place that would make sense is the bucket it&amp;rsquo;s about to create, which isn&amp;rsquo;t there yet. The thing that builds the state store can&amp;rsquo;t store its state in the state store.&lt;/p&gt;
&lt;h2 id="run-local-then-migrate"&gt;Run local, then migrate
&lt;/h2&gt;&lt;p&gt;The way out is a two-step that OpenTofu supports directly.&lt;/p&gt;
&lt;p&gt;Bootstrap starts configured with a &lt;em&gt;local&lt;/em&gt; backend: &lt;code&gt;backend &amp;quot;local&amp;quot; {}&lt;/code&gt;. State is just a file on the operator&amp;rsquo;s machine. With that in place, the first &lt;code&gt;tofu apply&lt;/code&gt; runs. It creates the S3 bucket and the KMS key, and records all of it in the local state file.&lt;/p&gt;
&lt;p&gt;Now the bucket exists. So the backend configuration is rewritten to point at it: an &lt;code&gt;s3&lt;/code&gt; backend block naming the new bucket. Then &lt;code&gt;tofu init -migrate-state&lt;/code&gt;. OpenTofu sees the backend has changed, picks up the local state file, and copies it into the S3 bucket. From that point on, bootstrap&amp;rsquo;s own state lives in the bucket that bootstrap created. The egg has laid the chicken.&lt;/p&gt;
&lt;p&gt;The local backend was a scaffold. It existed for exactly one apply, to break the ordering deadlock, and then the state moved off it and it was never used again.&lt;/p&gt;
&lt;h2 id="it-happened-twice"&gt;It happened twice
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;infra&lt;/code&gt; repo actually did this migration twice, and the second time is the proof that the pattern is general rather than a one-off trick.&lt;/p&gt;
&lt;p&gt;The first migration was the one above: local to S3, at the very start. The second came later, during the &lt;a class="link" href="https://phpboyscout.uk/why-we-left-github-for-gitlab/" &gt;move from GitHub to GitLab&lt;/a&gt;. GitLab offers a managed HTTP state backend, and &lt;code&gt;infra&lt;/code&gt; chose to use it. So the backend block was rewritten again, this time from &lt;code&gt;s3&lt;/code&gt; to &lt;code&gt;http&lt;/code&gt;, and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; ran again, copying the state from the S3 bucket to GitLab&amp;rsquo;s backend.&lt;/p&gt;
&lt;p&gt;The same move, twice, against three different backends. That&amp;rsquo;s the useful lesson hiding in the chicken-and-egg story. State is portable. The backend is just &lt;em&gt;where you currently keep it&lt;/em&gt;, not a property of the stack itself, and moving it is a routine, supported operation rather than surgery.&lt;/p&gt;
&lt;h2 id="why-this-is-the-honest-answer-not-a-hack"&gt;Why this is the honest answer, not a hack
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s easy to look at &amp;ldquo;apply once with a local backend, then migrate&amp;rdquo; and feel it&amp;rsquo;s a bit of a smell, a workaround for something that should have been cleaner.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t. It&amp;rsquo;s the honest answer to a real ordering problem, and the alternatives are worse.&lt;/p&gt;
&lt;p&gt;The obvious alternative is to create the state bucket by hand, in the console, before running bootstrap at all. But then the most important bucket in the account is unmanaged. It exists outside every OpenTofu graph, nobody&amp;rsquo;s code describes it, its encryption and policy and &lt;code&gt;prevent_destroy&lt;/code&gt; are whatever someone clicked that day, and it drifts. The local-then-migrate dance avoids exactly that. The bucket is created &lt;em&gt;by bootstrap&lt;/em&gt;, described in code, and tracked in bootstrap&amp;rsquo;s own state from its very first apply. It&amp;rsquo;s managed from birth.&lt;/p&gt;
&lt;p&gt;The chicken-and-egg isn&amp;rsquo;t a flaw to be embarrassed about. It&amp;rsquo;s just the shape of the problem when a stack has to build its own foundations, and OpenTofu&amp;rsquo;s &lt;code&gt;-migrate-state&lt;/code&gt; is the supported tool for exactly that shape.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;Every OpenTofu stack needs a backend to store state, and the bootstrap stack&amp;rsquo;s job is to &lt;em&gt;create&lt;/em&gt; the backend, so on its first run the bucket it needs doesn&amp;rsquo;t yet exist.&lt;/p&gt;
&lt;p&gt;The resolution is to run bootstrap once with a local backend, let that apply create the bucket and key, then rewrite the backend configuration and &lt;code&gt;tofu init -migrate-state&lt;/code&gt; the state into the bucket bootstrap just made. The &lt;code&gt;infra&lt;/code&gt; repo did it twice, local to S3 and later S3 to GitLab, which shows the real point: state is portable, and the backend is just where you keep it. Doing it this way, rather than hand-creating the bucket, is what keeps that critical bucket managed in code from its very first day.&lt;/p&gt;</description></item><item><title>The cleanup tool that almost deleted its own hands</title><link>https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/</guid><description>&lt;img src="https://phpboyscout.uk/the-cleanup-tool-that-almost-deleted-its-own-hands/cover-the-cleanup-tool-that-almost-deleted-its-own-hands.png" alt="Featured image of post The cleanup tool that almost deleted its own hands" /&gt;&lt;p&gt;The first time I pointed aws-nuke at a real account, the dry-run printed hundreds of lines of angry red text and my stomach dropped. Then I read it properly, and two things turned out to be true at once. Almost all of that red was noise. And the one operation I genuinely should have worried about wasn&amp;rsquo;t red at all.&lt;/p&gt;
&lt;h2 id="a-tool-whose-whole-job-is-destruction"&gt;A tool whose whole job is destruction
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://github.com/ekristen/aws-nuke" target="_blank" rel="noopener"
 &gt;aws-nuke&lt;/a&gt; deletes everything in an AWS account. That&amp;rsquo;s the point of it: when you spin up a throwaway account to try something, aws-nuke is how you tear it back down to nothing afterwards rather than leaving resources quietly billing forever. go-tool-base&amp;rsquo;s bootstrap renders a scoped aws-nuke config for exactly this, from a &lt;code&gt;nuke-config&lt;/code&gt; module, so the teardown is described in code rather than typed by hand at the worst possible moment.&lt;/p&gt;
&lt;p&gt;A tool that deletes everything is a tool you run in dry-run first, every single time, and read the output before you let it touch anything. So that&amp;rsquo;s what I did. And the output was alarming in a way that turned out to be completely meaningless, and reassuring in a way that turned out to hide the one real hazard.&lt;/p&gt;
&lt;h2 id="the-wall-of-red-that-means-nothing"&gt;The wall of red that means nothing
&lt;/h2&gt;&lt;p&gt;A fresh account threw up screen after screen of &lt;code&gt;SubscriptionRequiredException&lt;/code&gt;. Hundreds of lines, all red, all looking like something had gone badly wrong.&lt;/p&gt;
&lt;p&gt;They hadn&amp;rsquo;t. aws-nuke works by asking every region &amp;ldquo;do you have any of &lt;em&gt;this&lt;/em&gt; kind of resource?&amp;rdquo;, for every kind of resource it knows about. On a brand-new account you&amp;rsquo;ve never enabled most services in most regions, so the API&amp;rsquo;s honest answer is &amp;ldquo;you&amp;rsquo;re not subscribed to that here&amp;rdquo;, which surfaces as an exception, which the tool dutifully logs in red. It isn&amp;rsquo;t a failure. It&amp;rsquo;s the sound of an empty account being asked four hundred questions and answering &amp;ldquo;nothing here&amp;rdquo; to almost all of them.&lt;/p&gt;
&lt;p&gt;The skill, and it is a skill, is learning to read a destructive tool&amp;rsquo;s dry-run and tell the noise from the signal. &lt;code&gt;SubscriptionRequiredException&lt;/code&gt; on a fresh account is noise. Once you know that, the wall of red stops being frightening and becomes scenery.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a related trap in the same neighbourhood, and the &lt;code&gt;nuke-config&lt;/code&gt; module&amp;rsquo;s own &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/nuke-config/variables.tf#L11" target="_blank" rel="noopener"
 &gt;&lt;code&gt;regions&lt;/code&gt; variable documents it&lt;/a&gt;. aws-nuke has a special &lt;code&gt;global&lt;/code&gt; pseudo-region for things that don&amp;rsquo;t live in one place (IAM, Route 53, CloudFront), and then the actual regions for everything else. It &lt;em&gt;also&lt;/em&gt; accepts &lt;code&gt;all&lt;/code&gt;, meaning every enabled region. Mixing &lt;code&gt;all&lt;/code&gt; with explicit region values scans some regions twice and muddies the output, so the module&amp;rsquo;s guidance is to pick one approach or the other. More scenery you have to learn to read before the genuinely important line will stand out.&lt;/p&gt;
&lt;h2 id="the-line-that-should-have-scared-me-and-didnt-look-like-it"&gt;The line that should have scared me, and didn&amp;rsquo;t look like it
&lt;/h2&gt;&lt;p&gt;Buried in that calm-looking eye of the storm, among the resources aws-nuke intended to delete, were the IAM resources granting the identity &lt;em&gt;running the nuke&lt;/em&gt; its administrative access.&lt;/p&gt;
&lt;p&gt;Sit with that for a second. aws-nuke runs as some principal with enough power to delete everything. To delete EVERYTHING, it has to delete IAM resources too. And if the plan deletes the very grant that gives the running identity its admin &lt;em&gt;before&lt;/em&gt; it&amp;rsquo;s finished, the tool strands itself partway through: no permissions left to complete the teardown, and now you&amp;rsquo;ve got a half-nuked account and a principal that can&amp;rsquo;t act on it. The cleanup tool sawing off the branch it&amp;rsquo;s standing on, calmly, without a single red line to warn you, because from the API&amp;rsquo;s point of view deleting that resource is a perfectly valid request.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the operation that actually mattered, and it was the quietest thing in the output.&lt;/p&gt;
&lt;h2 id="two-ways-to-keep-its-hands-attached"&gt;Two ways to keep its hands attached
&lt;/h2&gt;&lt;p&gt;The fix has two halves, one explicit and one structural.&lt;/p&gt;
&lt;p&gt;The explicit half is to &lt;em&gt;preserve the privilege path&lt;/em&gt;. The &lt;code&gt;nuke-config&lt;/code&gt; module passes a caller-supplied set of &lt;code&gt;filters&lt;/code&gt; straight through into the rendered config, so you tell aws-nuke &amp;ldquo;everything except these&amp;rdquo;. You exclude the identity running the nuke, and the policy and path that grant it admin, from deletion. The tool cleans the account and leaves its own hands alone, because you told it which resources are off-limits.&lt;/p&gt;
&lt;p&gt;The structural half is to not give it a tempting separate thing to delete in the first place. If an identity gets its admin through an IAM &lt;em&gt;group&lt;/em&gt; it belongs to, that group is its own deletable resource, one more thing in the plan, one more way to be stranded. The automation role in &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; instead takes its policies as &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/automation-iam/main.tf#L119" target="_blank" rel="noopener"
 &gt;direct attachments to the role itself&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;gitlab&amp;#34;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;is_gitlab&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;policy_arns&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; {}
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; policy_arn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;value&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;No intermediary group sitting there as a separate, deletable object. Flattening the privilege onto the role makes the dependency simpler to reason about and gives the cleanup tool one fewer foot-gun to find. Belt and braces: filter the path out explicitly, &lt;em&gt;and&lt;/em&gt; don&amp;rsquo;t build a structure that invites the problem.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A destructive tool&amp;rsquo;s dry-run is the most valuable thing it produces, and reading it well is its own competence. On a fresh account, the screenfuls of red &lt;code&gt;SubscriptionRequiredException&lt;/code&gt; are noise, the sound of an empty account answering &amp;ldquo;nothing here&amp;rdquo;, and the &lt;code&gt;all&lt;/code&gt;-versus-&lt;code&gt;global&lt;/code&gt; region wrinkle is more of the same. Learn to see past all of it, because the operation that can actually hurt you is rarely the one shouting. Mine was the calm, unremarkable line proposing to delete the admin grant the nuke needed to finish its own job.&lt;/p&gt;
&lt;p&gt;Keep the cleanup tool&amp;rsquo;s hands attached: filter the privileged path out of the teardown so it&amp;rsquo;s never a candidate for deletion, and attach that privilege directly rather than through a group that&amp;rsquo;s just one more thing to delete. Then let it loose on everything else, which is, after all, what you brought it in to do.&lt;/p&gt;</description></item><item><title>The security finding you must not fix</title><link>https://phpboyscout.uk/the-security-finding-you-must-not-fix/</link><pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-security-finding-you-must-not-fix/</guid><description>&lt;img src="https://phpboyscout.uk/the-security-finding-you-must-not-fix/cover-the-security-finding-you-must-not-fix.png" alt="Featured image of post The security finding you must not fix" /&gt;&lt;p&gt;A security scanner flagged a finding in my Terraform, and the correct response, the one I had to talk myself into, was to leave it exactly as it was. Not because the finding was wrong about what the code did. It was right. It&amp;rsquo;s that doing what it asked would have quietly bricked the account.&lt;/p&gt;
&lt;h2 id="a-finding-that-looks-open-and-shut"&gt;A finding that looks open and shut
&lt;/h2&gt;&lt;p&gt;I run &lt;code&gt;checkov&lt;/code&gt; over the infrastructure as part of CI, and on the KMS key that protects the Terraform state bucket it raised CKV_AWS_111: a key policy that grants &lt;code&gt;kms:*&lt;/code&gt; is overly permissive. On the face of it, unarguable. The policy says the account root can perform &lt;em&gt;any&lt;/em&gt; KMS action on the key, with a resource of &lt;code&gt;*&lt;/code&gt;. A wildcard action and a wildcard resource is the exact shape a scanner is built to shout about, and ninety-nine times in a hundred it&amp;rsquo;d be right to.&lt;/p&gt;
&lt;p&gt;This was the hundredth time.&lt;/p&gt;
&lt;h2 id="why-narrowing-it-bricks-the-key"&gt;Why narrowing it bricks the key
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit that turns the finding on its head. That &lt;code&gt;kms:*&lt;/code&gt;-for-root statement isn&amp;rsquo;t an over-broad grant I left lying around. It&amp;rsquo;s the &lt;em&gt;default&lt;/em&gt; key policy AWS itself applies, and it&amp;rsquo;s load-bearing in a way that&amp;rsquo;s easy to miss.&lt;/p&gt;
&lt;p&gt;A KMS key is administered through its own key policy, and that policy is the &lt;em&gt;only&lt;/em&gt; way in. Unlike most resources, IAM permissions elsewhere can&amp;rsquo;t grant access to a key whose policy doesn&amp;rsquo;t allow it. So if you &amp;ldquo;tighten&amp;rdquo; the key by removing root&amp;rsquo;s full control, and you don&amp;rsquo;t perfectly replace it with some other administrative principal, you can end up with a key that &lt;em&gt;nobody&lt;/em&gt; can administer. Not you, not root, not a future you with a very good reason. KMS will not let you recover it. The key, and anything it encrypts, is stranded.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;kms:*&lt;/code&gt;-for-root statement is what keeps the account&amp;rsquo;s own root able to manage the key as a last resort. It&amp;rsquo;s not the vulnerability. It&amp;rsquo;s the escape hatch, and the scanner was asking me to weld it shut.&lt;/p&gt;
&lt;h2 id="so-the-finding-gets-suppressed-out-loud"&gt;So the finding gets suppressed, out loud
&lt;/h2&gt;&lt;p&gt;The answer isn&amp;rsquo;t to silence the scanner globally, and it isn&amp;rsquo;t to obey it. It&amp;rsquo;s to suppress &lt;em&gt;this finding, on this resource,&lt;/em&gt; with the reasoning written right there next to it, from &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L34" target="_blank" rel="noopener"
 &gt;&lt;code&gt;modules/state-backend/main.tf&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-hcl" data-lang="hcl"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;kms&amp;#34;&lt;/span&gt; {&lt;span class="c1"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # checkov:skip=CKV_AWS_111:kms:* on the CMK for the account root is the
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # AWS-documented pattern; narrowing it risks an unrecoverable lockout from
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt; # the key. See https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-default.html
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;statement&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; sid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AllowAccountRootAdmin&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; effect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Allow&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;principals&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;AWS&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; identifiers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;arn:aws:iam::${var.account_id}:root&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;kms:*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt; resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The skip carries a reason and a link to AWS&amp;rsquo;s own documentation of why this is the recommended default. That matters more than it looks. A bare &lt;code&gt;# checkov:skip&lt;/code&gt; with no explanation is indistinguishable from laziness, and the next person to read it (quite possibly me, a year on) has no way to tell whether it was a considered decision or someone making a red mark go away. A skip &lt;em&gt;with&lt;/em&gt; a documented reason is a decision you can audit. The finding is still visible in the sense that the suppression is right there in the code, attached to the thing it&amp;rsquo;s about, defensible out loud.&lt;/p&gt;
&lt;h2 id="a-scanner-is-an-argument-not-an-order"&gt;A scanner is an argument, not an order
&lt;/h2&gt;&lt;p&gt;The wider lesson is the one worth keeping, because it generalises well past this one key. A static-analysis finding is a &lt;em&gt;prompt to think&lt;/em&gt;, not an instruction to comply with. Most of the time thinking leads you straight to &amp;ldquo;yes, fix it&amp;rdquo;, and you should. But a scanner encodes a general rule, and general rules meet specific contexts where they&amp;rsquo;re wrong, or merely irrelevant, or, in the rare and dangerous case, actively harmful to obey. &lt;code&gt;kms:*&lt;/code&gt; for root on a customer-managed key is that last kind: the tool&amp;rsquo;s general rule (&amp;ldquo;wildcards are bad&amp;rdquo;) collides with a hard AWS-specific fact (&amp;ldquo;root must retain control of the key or it&amp;rsquo;s gone&amp;rdquo;).&lt;/p&gt;
&lt;p&gt;The discipline that keeps this honest is the one in the code above. You don&amp;rsquo;t get to ignore a finding. You get to &lt;em&gt;suppress&lt;/em&gt; it, scoped to the exact resource, with a reason a reviewer can weigh. Cheap enough that you&amp;rsquo;ll do it properly, costly enough that you won&amp;rsquo;t paper over a real finding by reflex.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;checkov&lt;/code&gt; was right that the state-bucket key grants the account root &lt;code&gt;kms:*&lt;/code&gt; on &lt;code&gt;*&lt;/code&gt;. It was wrong that I should narrow it, because that statement is AWS&amp;rsquo;s documented default and the thing that stops the key becoming permanently unadministrable. The fix was a scoped &lt;code&gt;checkov:skip&lt;/code&gt; carrying its reasoning and a link to the AWS docs, so the decision lives next to the code and can be defended rather than merely trusted.&lt;/p&gt;
&lt;p&gt;Treat your scanner as a sharp, tireless colleague who&amp;rsquo;s usually right and occasionally, confidently, about to lock you out of your own key. Read every finding. Obey most of them. And write down, in the open, the rare one you mustn&amp;rsquo;t.&lt;/p&gt;</description></item><item><title>Two telemetry events, one mangled line</title><link>https://phpboyscout.uk/two-events-one-mangled-line/</link><pubDate>Sun, 03 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-events-one-mangled-line/</guid><description>&lt;img src="https://phpboyscout.uk/two-events-one-mangled-line/cover-two-events-one-mangled-line.png" alt="Featured image of post Two telemetry events, one mangled line" /&gt;&lt;p&gt;A line in a log file that no parser would touch. Not a wrong value, not a missing field. Half of one telemetry event spliced into the middle of another, like two people typing into the same text box at once. Which, it turns out, is pretty much exactly what had happened.&lt;/p&gt;
&lt;h2 id="a-format-with-exactly-one-rule"&gt;A format with exactly one rule
&lt;/h2&gt;&lt;p&gt;rust-tool-base writes its telemetry to a file as JSONL: one JSON object per line, newline at the end, next object on the next line. It&amp;rsquo;s a lovely format to work with precisely because it has one rule, and the rule is simple. Every line is a complete object. Honour that and you can &lt;code&gt;tail&lt;/code&gt; it, &lt;code&gt;grep&lt;/code&gt; it, stream it into anything. Break it once and the whole file is suspect, because now a reader can&amp;rsquo;t trust that a line is a line.&lt;/p&gt;
&lt;p&gt;So the one job the file sink has, beyond writing the right bytes, is to never let two events end up sharing a line.&lt;/p&gt;
&lt;h2 id="appending-is-atomic-though-isnt-it"&gt;&amp;ldquo;Appending is atomic, though, isn&amp;rsquo;t it?&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;The mental model I started with, and I suspect I&amp;rsquo;m not alone, was this: open the file with &lt;code&gt;O_APPEND&lt;/code&gt;, write the serialised event, and the operating system tacks it onto the end atomically. Two writers can&amp;rsquo;t tread on each other because each &lt;code&gt;write&lt;/code&gt; goes to wherever the end currently is, no questions asked. I&amp;rsquo;d half-remembered &lt;code&gt;O_APPEND&lt;/code&gt; as the thing that makes concurrent appending safe, full stop.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s half true, and the half that&amp;rsquo;s missing is the half that bit me.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; does guarantee one thing: the seek-to-end and the write happen as a unit, so you never get the classic lost-update where two writers compute the same offset and clobber each other. Good. What it does &lt;em&gt;not&lt;/em&gt; guarantee, on POSIX, is that a single &lt;code&gt;write()&lt;/code&gt; of arbitrary size is atomic with respect to other writers. That atomicity has a ceiling, and the ceiling is &lt;code&gt;PIPE_BUF&lt;/code&gt;: 4096 bytes on Linux. Under it, a write is all-or-nothing against other writes to the same file. Over it, the kernel is entirely within its rights to split your write into chunks, and another writer&amp;rsquo;s bytes can land in the gap between them.&lt;/p&gt;
&lt;h2 id="the-fat-event-that-went-over-the-edge"&gt;The fat event that went over the edge
&lt;/h2&gt;&lt;p&gt;For a long time nothing went wrong, which is the most dangerous way for a bug like this to behave. A typical event, a command name, a duration, a status, an attribute or two, serialises to a few hundred bytes. Comfortably under four kilobytes, so comfortably inside the atomic window. Hundreds of them a day, never a problem.&lt;/p&gt;
&lt;p&gt;Then an event came along with a lot of attributes on it, and its serialised form sailed past 4 KiB. Two of &lt;em&gt;those&lt;/em&gt; emitted at roughly the same moment, both over the line, and &lt;code&gt;O_APPEND&lt;/code&gt; did the only thing it had ever promised: it put each write at the end. It said nothing about not interleaving the bytes on the way, because past &lt;code&gt;PIPE_BUF&lt;/code&gt; that was never on offer. One spliced line, one file a parser would now choke on.&lt;/p&gt;
&lt;h2 id="the-fix-isnt-a-bigger-write-its-a-smaller-gate"&gt;The fix isn&amp;rsquo;t a bigger write, it&amp;rsquo;s a smaller gate
&lt;/h2&gt;&lt;p&gt;You can&amp;rsquo;t buy your way out of this with a bigger buffer, because there&amp;rsquo;s no buffer size that&amp;rsquo;s reliably atomic above &lt;code&gt;PIPE_BUF&lt;/code&gt;. The fix is to stop relying on the kernel for mutual exclusion you can do yourself: serialise the events through a lock, so only one &lt;code&gt;write&lt;/code&gt; is ever in flight at a time. The &lt;code&gt;FileSink&lt;/code&gt; carries a mutex for exactly that, and the doc comment on it is the whole post in a paragraph, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-telemetry/src/sink.rs#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-telemetry/src/sink.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;FileSink&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;: &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Serialises concurrent `emit` calls. Shared across `Clone`s of
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// the same `FileSink` so multiple handles to the same path also
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// serialise correctly.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;: &lt;span class="nc"&gt;Arc&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;tokio&lt;/span&gt;::&lt;span class="n"&gt;sync&lt;/span&gt;::&lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you don&amp;rsquo;t write Rust day to day (the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;primer&lt;/a&gt; has the rest of the basics): &lt;code&gt;tokio::sync::Mutex&lt;/code&gt; is an async-aware lock, &lt;code&gt;.await&lt;/code&gt; is where a task waits its turn for that lock without blocking the whole thread, and the &lt;code&gt;Arc&lt;/code&gt; wrapper is shared ownership. That &lt;code&gt;Arc&lt;/code&gt; is the load-bearing bit: it means every clone of the &lt;code&gt;FileSink&lt;/code&gt; points at the &lt;em&gt;same&lt;/em&gt; gate, rather than each getting its own lock that guards nothing.&lt;/p&gt;
&lt;p&gt;The detail I like is &lt;em&gt;where&lt;/em&gt; the lock sits. The event is serialised to a string first, outside the critical section, because turning an event into JSON is the expensive part and there&amp;rsquo;s no reason to hold the gate while you do it. Only then does &lt;code&gt;emit&lt;/code&gt; take the lock, and it holds it across the whole open-write-flush, so no other emit can interleave a single byte:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Serialise the line outside the critical section.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;serde_json&lt;/span&gt;::&lt;span class="n"&gt;to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;redacted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;&amp;#39;\n&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;_guard&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...create parent dir, open with append(true)...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_bytes&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;write_all&lt;/code&gt; makes sure the whole line goes out as one logical write from our side, and the gate makes sure ours is the only one happening. The 4 KiB cliff is still there in the kernel. We just never walk near it any more, because we&amp;rsquo;ve serialised the writers ourselves rather than hoping the OS would.&lt;/p&gt;
&lt;h2 id="the-bit-even-the-lock-cant-fix"&gt;The bit even the lock can&amp;rsquo;t fix
&lt;/h2&gt;&lt;p&gt;There is however a genuine limit, and the comment is upfront about it. The mutex lives in the process. Two &lt;code&gt;FileSink&lt;/code&gt;s in two &lt;em&gt;different&lt;/em&gt; processes, both pointed at the same file, are back to relying on &lt;code&gt;O_APPEND&lt;/code&gt; alone, and back under the 4 KiB ceiling. The lock can&amp;rsquo;t reach across a process boundary, so it doesn&amp;rsquo;t pretend to. The guidance there is the older, duller, correct one: give each process its own file and aggregate them somewhere else. Don&amp;rsquo;t have two processes fighting over one log file and expect the filesystem to referee.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;O_APPEND&lt;/code&gt; is a real guarantee, just a much smaller one than its name talks you into. It keeps your write at the end of the file, and it keeps concurrent writes from interleaving only while they stay under &lt;code&gt;PIPE_BUF&lt;/code&gt;, which on Linux is 4096 bytes. A fat JSON event slides straight over that and takes your file&amp;rsquo;s one rule with it.&lt;/p&gt;
&lt;p&gt;The fix was never exotic. Serialise the line, take a mutex, do the write under it, and the interleave can&amp;rsquo;t happen because there&amp;rsquo;s only ever one writer at a time. The POSIX manual had all of this written down long before I went and learned it the interesting way, which is, I&amp;rsquo;m told, how most people meet &lt;code&gt;PIPE_BUF&lt;/code&gt; too.&lt;/p&gt;</description></item><item><title>A state bucket that defends itself</title><link>https://phpboyscout.uk/a-state-bucket-that-defends-itself/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-state-bucket-that-defends-itself/</guid><description>&lt;img src="https://phpboyscout.uk/a-state-bucket-that-defends-itself/cover-a-state-bucket-that-defends-itself.png" alt="Featured image of post A state bucket that defends itself" /&gt;&lt;p&gt;OpenTofu&amp;rsquo;s remote state file is, quietly, the most sensitive thing in an infrastructure repo. It&amp;rsquo;s a plain JSON document listing every resource you manage, every ID, and, depending on your providers, the odd secret in clear text. So the S3 bucket that holds it can&amp;rsquo;t just be a bucket. It has to actively defend itself, on three separate fronts.&lt;/p&gt;
&lt;h2 id="the-most-sensitive-file-in-the-repo"&gt;The most sensitive file in the repo
&lt;/h2&gt;&lt;p&gt;OpenTofu, like Terraform, keeps a state file: a JSON document recording every resource the stack manages, its real-world ID, and its attributes. It&amp;rsquo;s how the tool knows what already exists. It&amp;rsquo;s also, quietly, the most sensitive file in the whole repo. It can hold resource identifiers an attacker would value, and depending on the providers in play it can hold secret values in clear text.&lt;/p&gt;
&lt;p&gt;Three bad things can happen to it. It can be deleted, and now the tool has forgotten everything it manages. It can be read by someone who shouldn&amp;rsquo;t. It can be corrupted by two runs writing at once. The bucket that holds remote state has to defend against all three, and &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s &lt;code&gt;state-backend&lt;/code&gt; module is built around doing exactly that.&lt;/p&gt;
&lt;h2 id="the-dynamodb-lock-table-is-gone"&gt;The DynamoDB lock table is gone
&lt;/h2&gt;&lt;p&gt;Start with the corruption problem, because the answer changed recently.&lt;/p&gt;
&lt;p&gt;The long-standing pattern for remote state on AWS was an S3 bucket &lt;em&gt;plus&lt;/em&gt; a DynamoDB table. S3 held the state; the DynamoDB table held a lock, so two &lt;code&gt;apply&lt;/code&gt; runs couldn&amp;rsquo;t write at once. Everyone who&amp;rsquo;s done Terraform on AWS has provisioned that table, probably more times than they&amp;rsquo;d care to count.&lt;/p&gt;
&lt;p&gt;OpenTofu 1.10 made it unnecessary. The S3 backend gained &lt;code&gt;use_lockfile&lt;/code&gt;, which does the locking with a small lock &lt;em&gt;object&lt;/em&gt; in the same bucket, using S3&amp;rsquo;s conditional-write support. No separate table. The state backend is now genuinely one bucket and one key, with the lock living beside the state. It&amp;rsquo;s one fewer resource to create, one fewer thing to pay for, and one fewer moving part to reason about. The module takes the new path, and the DynamoDB table simply isn&amp;rsquo;t there.&lt;/p&gt;
&lt;h2 id="a-bucket-you-cant-delete-by-accident"&gt;A bucket you can&amp;rsquo;t delete by accident
&lt;/h2&gt;&lt;p&gt;Deletion is guarded with &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L66" target="_blank" rel="noopener"
 &gt;&lt;code&gt;lifecycle { prevent_destroy = true }&lt;/code&gt;&lt;/a&gt; on the bucket. With that set, OpenTofu refuses to produce a plan that would destroy the bucket. A stray &lt;code&gt;tofu destroy&lt;/code&gt;, a refactor that drops the resource, an accidental rename: all of them fail loudly instead of quietly taking the state bucket with them.&lt;/p&gt;
&lt;p&gt;This is also why the &lt;code&gt;state-backend&lt;/code&gt; module is hand-rolled from raw &lt;code&gt;aws_s3_bucket&lt;/code&gt; resources rather than wrapping a community module like &lt;code&gt;terraform-aws-modules/s3-bucket&lt;/code&gt;. &lt;code&gt;prevent_destroy&lt;/code&gt; has to sit on the actual resource, and a &lt;code&gt;lifecycle&lt;/code&gt; block isn&amp;rsquo;t something you can pass into a wrapper module as an input. Hand-rolling the bucket keeps &lt;code&gt;prevent_destroy&lt;/code&gt; somewhere you can put it and, just as importantly, somewhere the next reader can see it. (There&amp;rsquo;s a whole post coming on &lt;a class="link" href="https://phpboyscout.uk/why-i-hand-rolled-every-module/" &gt;why I hand-rolled every module&lt;/a&gt;; this is one of the reasons in miniature.)&lt;/p&gt;
&lt;h2 id="reject-anything-encrypted-wrong"&gt;Reject anything encrypted wrong
&lt;/h2&gt;&lt;p&gt;Confidentiality is the subtle one, because the obvious control isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;The bucket has a default encryption configuration: server-side encryption with the customer-managed KMS key. But default encryption is a &lt;em&gt;default&lt;/em&gt;. A client making a &lt;code&gt;PutObject&lt;/code&gt; call can override it per request, asking for plain &lt;code&gt;AES256&lt;/code&gt; or a different KMS key, and S3 will honour the override.&lt;/p&gt;
&lt;p&gt;So the module doesn&amp;rsquo;t rely on the default. The &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/state-backend/main.tf#L161" target="_blank" rel="noopener"
 &gt;bucket policy&lt;/a&gt; explicitly denies the upload it doesn&amp;rsquo;t want. It denies any request not over TLS. It denies any &lt;code&gt;PutObject&lt;/code&gt; that isn&amp;rsquo;t using SSE-KMS. And it denies any &lt;code&gt;PutObject&lt;/code&gt; that names the &lt;em&gt;wrong&lt;/em&gt; KMS key. The default encryption config says &amp;ldquo;this is what you get if you don&amp;rsquo;t ask&amp;rdquo;; the bucket policy says &amp;ldquo;and you&amp;rsquo;re not allowed to ask for anything else&amp;rdquo;. State can only ever land encrypted, in transit and at rest, under the one key the module controls.&lt;/p&gt;
&lt;p&gt;One small companion setting: &lt;code&gt;bucket_key_enabled&lt;/code&gt;. With per-object SSE-KMS, every object operation is also a KMS API call, which costs money and can throttle. An S3 Bucket Key collapses those into far fewer KMS calls, cutting per-object KMS traffic by well over ninety per cent. It&amp;rsquo;s a one-line setting the module turns on and most people forget exists.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Remote state is the most sensitive file an infrastructure repo has, and the bucket that holds it has to defend against deletion, disclosure and corruption.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&amp;rsquo;s state backend handles corruption with OpenTofu 1.10&amp;rsquo;s &lt;code&gt;use_lockfile&lt;/code&gt;, dropping the old DynamoDB lock table entirely. It guards deletion with &lt;code&gt;prevent_destroy&lt;/code&gt;, which is also why the bucket is hand-rolled rather than wrapped. And it guards confidentiality with a bucket policy that denies non-TLS traffic and denies any upload not encrypted with the right KMS key, because default encryption is only a default and a client can override it. The state bucket isn&amp;rsquo;t just a place to put state. It&amp;rsquo;s built to refuse every wrong thing that could happen to it.&lt;/p&gt;</description></item><item><title>Supporting a provider, or actually using it</title><link>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</link><pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/</guid><description>&lt;img src="https://phpboyscout.uk/supporting-a-provider-or-actually-using-it/cover-supporting-a-provider-or-actually-using-it.png" alt="Featured image of post Supporting a provider, or actually using it" /&gt;&lt;p&gt;If your CLI tool talks to an AI model, you don&amp;rsquo;t want to hard-wire one vendor. So you reach for a single client interface over several providers, which is the right call. The trap is the next step: build that interface on only what every provider has in common, and you quietly throw away the very features that made you want a particular provider in the first place. rust-tool-base&amp;rsquo;s &lt;code&gt;rtb-ai&lt;/code&gt; refuses to make that trade.&lt;/p&gt;
&lt;h2 id="the-pull-toward-one-interface"&gt;The pull toward one interface
&lt;/h2&gt;&lt;p&gt;If your CLI tool talks to an AI model, hard-wiring one vendor is a poor bet. One user has an Anthropic key, another an OpenAI key. Someone&amp;rsquo;s on Gemini. Someone runs Ollama locally because their data can&amp;rsquo;t leave the building. Someone points at an OpenAI-compatible endpoint from a provider you&amp;rsquo;ve never heard of. You don&amp;rsquo;t want a separate code path for each, so you want one &lt;code&gt;AiClient&lt;/code&gt; that all of them slot behind.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; gets that unification from the &lt;code&gt;genai&lt;/code&gt; crate, which already speaks to Anthropic, OpenAI, Gemini, Ollama and OpenAI-compatible endpoints. One interface, five providers, the tool author picks one in config. The Go sibling makes the same bet: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package also unifies several providers, behind &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;an interface deliberately kept to four methods&lt;/a&gt;. So far this is the obvious design, and if it were the whole design there&amp;rsquo;d be nothing to write about.&lt;/p&gt;
&lt;h2 id="what-unified-quietly-costs-you"&gt;What &amp;ldquo;unified&amp;rdquo; quietly costs you
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the catch in any unified interface. It can only expose what every provider behind it has in common.&lt;/p&gt;
&lt;p&gt;The common subset is plain chat. Messages go in, text comes out, optionally streamed token by token. That&amp;rsquo;s real and it&amp;rsquo;s useful and every provider does it. But the common subset is also the &lt;em&gt;floor&lt;/em&gt;, and the features that make a particular provider worth choosing are almost never on the floor. They&amp;rsquo;re the things only that provider does.&lt;/p&gt;
&lt;p&gt;Anthropic is the sharp example, because it has three features that matter and not one of them is common-subset.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt caching.&lt;/strong&gt; You can mark the stable parts of a request, the system prompt and the tool list, as cacheable. The provider keeps them warm, and on the next turn you aren&amp;rsquo;t billed to re-send and re-process text that didn&amp;rsquo;t change. On a long agent loop, where the same large system prompt rides along on every single turn, that&amp;rsquo;s a substantial saving in both cost and latency.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extended thinking.&lt;/strong&gt; The model works through a hard problem in a visible, budgeted reasoning pass before it commits to an answer, and you can see that reasoning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Citations.&lt;/strong&gt; Structured references back to source material in the response.&lt;/p&gt;
&lt;p&gt;A client built strictly on the common subset can&amp;rsquo;t express any of those. It has no field for them, because four of the five providers wouldn&amp;rsquo;t know what to do with the field. So a purely lowest-common-denominator client would &amp;ldquo;support&amp;rdquo; Anthropic and then use it badly, leaving its best features unreachable. Support as a checkbox, not as the point.&lt;/p&gt;
&lt;h2 id="the-escape-hatch"&gt;The escape hatch
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt;&amp;rsquo;s answer is to not choose. It runs two implementations under one interface.&lt;/p&gt;
&lt;p&gt;For OpenAI, Gemini, Ollama and OpenAI-compatible endpoints, calls route through &lt;code&gt;genai&lt;/code&gt;, the unified path. For Anthropic, every method drops to a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/anthropic.rs#L1" target="_blank" rel="noopener"
 &gt;direct &lt;code&gt;reqwest&lt;/code&gt; implementation&lt;/a&gt; straight against the Messages API. Same &lt;code&gt;AiClient&lt;/code&gt; on the surface, a different implementation underneath, selected by which provider the config names.&lt;/p&gt;
&lt;p&gt;And the request type has deliberate room for the difference:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;ChatRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;: &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;temperature&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;f32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: enables prompt caching at every stable point.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_control&lt;/span&gt;: &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Anthropic-only: extended-thinking budget. `None` disables.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="sd"&gt;/// Ignored on non-Anthropic providers.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;: &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ThinkingMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Set &lt;code&gt;cache_control&lt;/code&gt; and the Anthropic-direct path inserts cache breakpoints at the three stable points: the system prompt, the tool list, and the first message. Set &lt;code&gt;thinking&lt;/code&gt; and it adds the thinking block, and streaming surfaces a separate &lt;code&gt;ThinkingToken&lt;/code&gt; event so you can show the reasoning apart from the answer. On a non-Anthropic provider, both fields are simply ignored. The interface carries them; only the implementation that understands them acts on them.&lt;/p&gt;
&lt;h2 id="a-hatch-not-a-leak"&gt;A hatch, not a leak
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why this isn&amp;rsquo;t the thing it superficially resembles, which is a leaky abstraction.&lt;/p&gt;
&lt;p&gt;A leaky abstraction is one where implementation details bleed through that you didn&amp;rsquo;t intend and can&amp;rsquo;t reason about. The abstraction quietly fails to abstract, and you&amp;rsquo;re left guessing which provider you&amp;rsquo;re really talking to.&lt;/p&gt;
&lt;p&gt;This is the opposite of that. The two Anthropic-only fields aren&amp;rsquo;t a leak. They&amp;rsquo;re named, documented as Anthropic-only, inert everywhere else, and right there in the public type for anyone to see. The interface is uniform for the common case and &lt;em&gt;deliberately, visibly&lt;/em&gt; non-uniform at exactly the points where uniformity would have cost you the good features. You opt into provider-specifics by setting a field. You stay fully portable by leaving it at its default. Nothing bleeds; you decide.&lt;/p&gt;
&lt;p&gt;The same design line explains what &lt;em&gt;does&lt;/em&gt; stay in the unified path. Structured output, &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, sends a JSON Schema derived from your Rust type with the request and validates the reply against it before handing you a typed &lt;code&gt;T&lt;/code&gt;. That&amp;rsquo;s a portability win that costs nothing across providers, so it belongs in the common interface. The split isn&amp;rsquo;t &amp;ldquo;Anthropic versus the rest&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;features that are free to unify go in the unified path; features that aren&amp;rsquo;t get a designed door&amp;rdquo;. Prompt caching and extended thinking get the door, because flattening them away would be the expensive kind of convenient.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A CLI tool that integrates AI wants one client over several providers, and a unified interface can only expose what those providers share. The shared floor is plain chat, and the features worth choosing a provider &lt;em&gt;for&lt;/em&gt;, like Anthropic&amp;rsquo;s prompt caching, extended thinking and citations, are never on the floor.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rtb-ai&lt;/code&gt; keeps both. &lt;code&gt;genai&lt;/code&gt; provides the unified path across five providers; an Anthropic-direct &lt;code&gt;reqwest&lt;/code&gt; path drops below the abstraction for the features &lt;code&gt;genai&lt;/code&gt; can&amp;rsquo;t reach, and &lt;code&gt;ChatRequest&lt;/code&gt; carries the Anthropic-only fields openly, ignored elsewhere. Uniform where uniformity is free, with a designed escape hatch where it isn&amp;rsquo;t. That&amp;rsquo;s the difference between supporting a provider and actually using it.&lt;/p&gt;</description></item><item><title>Errors without an error handler</title><link>https://phpboyscout.uk/errors-without-an-error-handler/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/errors-without-an-error-handler/</guid><description>&lt;img src="https://phpboyscout.uk/errors-without-an-error-handler/cover-errors-without-an-error-handler.png" alt="Featured image of post Errors without an error handler" /&gt;&lt;p&gt;In &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;the porting post&lt;/a&gt; I said go-tool-base&amp;rsquo;s error handler was one of the bits that &lt;em&gt;didn&amp;rsquo;t&lt;/em&gt; survive the move to Rust, and promised to come back to it. Here&amp;rsquo;s the come-back. The short version is that Rust hands you, for free, the single consistent error exit that go-tool-base had to build a whole component to get.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-built"&gt;What go-tool-base built
&lt;/h2&gt;&lt;p&gt;A while ago I &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;wrote about error handling in go-tool-base&lt;/a&gt;. The core of it: an error should carry a &lt;em&gt;hint&lt;/em&gt;, a separate field of human guidance telling the user what to do next, kept apart from the error&amp;rsquo;s identity so code can still match on it.&lt;/p&gt;
&lt;p&gt;The other half of that post was about consistency. Every go-tool-base command returns its errors the idiomatic Cobra way, and they all funnel into one &lt;code&gt;Execute()&lt;/code&gt; wrapper at the root, which routes every error through one &lt;code&gt;ErrorHandler&lt;/code&gt;. One door out. Presentation decided in exactly one place, so no command can render a failure differently from its neighbour.&lt;/p&gt;
&lt;p&gt;That handler is a real object. It exists, it&amp;rsquo;s wired in, it&amp;rsquo;s the thing every error passes through. Building it was a deliberate piece of work, and it was the right call for Go.&lt;/p&gt;
&lt;p&gt;When I rebuilt this in Rust, the handler didn&amp;rsquo;t survive the move. Not because consistency stopped mattering. Because Rust gives you the single exit for free, and an object to enforce it would just be re-implementing something the language already does for you.&lt;/p&gt;
&lt;h2 id="the-shape-of-a-rust-error"&gt;The shape of a Rust error
&lt;/h2&gt;&lt;p&gt;Start with the type. In rust-tool-base every crate defines its own error enum, and every one of them derives two traits:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[derive(Debug, thiserror::Error, miette::Diagnostic)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;ConfigError&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[error(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;config file not found at {path}&amp;#34;&lt;/span&gt;&lt;span class="cp"&gt;)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;#[diagnostic(
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; code(rtb::config::not_found),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; help(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;run `mytool init` to create one, or set MYTOOL_CONFIG&amp;#34;&lt;/span&gt;&lt;span class="cp"&gt;),
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt; )]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NotFound&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;: &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;thiserror::Error&lt;/code&gt; makes it a proper error type. &lt;code&gt;miette::Diagnostic&lt;/code&gt; is the interesting one. A &lt;code&gt;Diagnostic&lt;/code&gt; is an error that also carries the things you&amp;rsquo;d want when &lt;em&gt;presenting&lt;/em&gt; it: a stable &lt;code&gt;code&lt;/code&gt;, a severity, a &lt;code&gt;help&lt;/code&gt; string, and optionally source labels pointing at spans of input. The &lt;code&gt;help&lt;/code&gt; line is the same idea as go-tool-base&amp;rsquo;s hint, the recovery step, except here it&amp;rsquo;s an attribute on the variant rather than a field threaded through a wrapper.&lt;/p&gt;
&lt;p&gt;So the guidance lives on the error, structured, from the moment the error is created.&lt;/p&gt;
&lt;h2 id="there-is-no-handler-theres-a-convention"&gt;There is no handler, there&amp;rsquo;s a convention
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where Rust does the work go-tool-base&amp;rsquo;s handler was built to do.&lt;/p&gt;
&lt;p&gt;A rust-tool-base &lt;code&gt;main&lt;/code&gt; looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[tokio::main]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;miette&lt;/span&gt;::&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rtb&lt;/span&gt;::&lt;span class="n"&gt;cli&lt;/span&gt;::&lt;span class="n"&gt;Application&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;VersionInfo&lt;/span&gt;::&lt;span class="n"&gt;from_env&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;main&lt;/code&gt; returns &lt;code&gt;miette::Result&amp;lt;()&amp;gt;&lt;/code&gt;. Every command&amp;rsquo;s &lt;code&gt;run&lt;/code&gt; returns a &lt;code&gt;Result&lt;/code&gt; too. In between, errors propagate with the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;code&gt;?&lt;/code&gt; operator&lt;/a&gt;: a function that hits an error returns it upward, immediately, and the caller does the same, all the way to &lt;code&gt;main&lt;/code&gt;. Nobody writes a &amp;ldquo;check this error&amp;rdquo; call. &lt;code&gt;?&lt;/code&gt; is the propagation.&lt;/p&gt;
&lt;p&gt;And when an error reaches &lt;code&gt;main&lt;/code&gt; and &lt;code&gt;main&lt;/code&gt; returns it, &lt;em&gt;something&lt;/em&gt; has to render it for the user. That something is a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-error/src/hook.rs#L41" target="_blank" rel="noopener"
 &gt;report hook&lt;/a&gt;. rust-tool-base installs one at startup, and from then on any &lt;code&gt;Diagnostic&lt;/code&gt; that exits &lt;code&gt;main&lt;/code&gt; is rendered through it: the code, the severity, the help text, the source labels, with colour. One renderer, installed once.&lt;/p&gt;
&lt;p&gt;Look at what that adds up to. Every error in the program flows to one place, &lt;code&gt;main&lt;/code&gt;. It&amp;rsquo;s rendered by one thing, the hook. Presentation is decided in exactly one location and no command can deviate from it. That&amp;rsquo;s precisely the property go-tool-base&amp;rsquo;s &lt;code&gt;ErrorHandler&lt;/code&gt; was built to guarantee. The difference is that nobody built it. The single exit is just where &lt;code&gt;?&lt;/code&gt; propagation ends, and the single renderer is one hook. The language&amp;rsquo;s own convention for returning errors from &lt;code&gt;main&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the funnel.&lt;/p&gt;
&lt;h2 id="errors-are-values-all-the-way"&gt;Errors are values, all the way
&lt;/h2&gt;&lt;p&gt;The thing that took me a moment to fully trust is that there&amp;rsquo;s no funnel to maintain, because there&amp;rsquo;s no funnel as an object. go-tool-base&amp;rsquo;s handler is a component: it can drift, it has to be kept in the path, a command could in principle be wired to bypass it. The Rust version cannot be bypassed, because bypassing it would mean a command not returning its error, and an error you don&amp;rsquo;t return is a compile-time warning at best and dead-obvious wrong code at worst.&lt;/p&gt;
&lt;p&gt;So the model is just: errors are values, you return them, &lt;code&gt;?&lt;/code&gt; carries them up, &lt;code&gt;main&lt;/code&gt; hands the last one to the hook. The consistency isn&amp;rsquo;t enforced by a guard. It&amp;rsquo;s the only thing the shape of the language really lets you do.&lt;/p&gt;
&lt;p&gt;go-tool-base reaches a single, consistent error exit by building one and routing everything through it. rust-tool-base reaches the same exit by having errors be ordinary return values and letting them fall out of &lt;code&gt;main&lt;/code&gt;. Same outcome. One of them is a component you own; the other is a convention you inherit.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;go-tool-base funnels every error through one &lt;code&gt;ErrorHandler&lt;/code&gt; so presentation stays consistent. That handler is a deliberately built component, and it&amp;rsquo;s the right design in Go.&lt;/p&gt;
&lt;p&gt;rust-tool-base has no handler. Every crate&amp;rsquo;s error type derives &lt;code&gt;miette::Diagnostic&lt;/code&gt;, carrying its code, severity and help text. Errors propagate with &lt;code&gt;?&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt;, which returns &lt;code&gt;miette::Result&lt;/code&gt;, and a framework-installed hook renders whatever comes out. The single consistent exit is the end of &lt;code&gt;?&lt;/code&gt; propagation, and the single renderer is one hook. The funnel go-tool-base built by hand is, in Rust, just the language&amp;rsquo;s return-from-&lt;code&gt;main&lt;/code&gt; convention.&lt;/p&gt;</description></item><item><title>The bootstrap that does almost nothing</title><link>https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/</guid><description>&lt;img src="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/cover-the-bootstrap-that-does-almost-nothing.png" alt="Featured image of post The bootstrap that does almost nothing" /&gt;&lt;p&gt;A brand-new AWS account is a slightly nerve-wracking thing. It can do almost anything, it&amp;rsquo;s hardened against almost nothing, and the list of stuff you ought to set up before you trust it with anything real is long. The natural instinct is to write one big &amp;ldquo;set up the account&amp;rdquo; module that does the whole list in a single apply. I want to talk you out of that, because the bootstrap module I&amp;rsquo;m happiest with does almost nothing, on purpose.&lt;/p&gt;
&lt;h2 id="the-first-apply-problem"&gt;The first-apply problem
&lt;/h2&gt;&lt;p&gt;A brand-new AWS account is not ready for anything serious. Before you&amp;rsquo;d responsibly run real infrastructure into it, you want an account baseline: a password policy, account-wide S3 public-access blocking, default EBS encryption, CloudTrail, AWS Config, GuardDuty, alerting, a sensible human operator role. It&amp;rsquo;s a long list, and all of it matters.&lt;/p&gt;
&lt;p&gt;The instinct, faced with that list, is to write one big &amp;ldquo;set up the account&amp;rdquo; module and have it do everything. One &lt;code&gt;tofu apply&lt;/code&gt;, a fully prepared account, done.&lt;/p&gt;
&lt;p&gt;That instinct is worth resisting, and &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/tree/v0.2.0/modules" target="_blank" rel="noopener"
 &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt; resists it deliberately.&lt;/p&gt;
&lt;h2 id="three-things-and-a-hard-line"&gt;Three things, and a hard line
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does three things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state-backend&lt;/code&gt;&lt;/strong&gt;, an S3 bucket and a customer-managed KMS key to hold remote Terraform state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;automation-iam&lt;/code&gt;&lt;/strong&gt;, an OIDC identity provider and an IAM role that CI assumes to apply everything else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nuke-config&lt;/code&gt;&lt;/strong&gt;, which renders an &lt;a class="link" href="https://github.com/ekristen/aws-nuke" target="_blank" rel="noopener"
 &gt;aws-nuke&lt;/a&gt; configuration scoped to the account, for tearing a throwaway account back down.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the whole module. Account hardening, CloudTrail, AWS Config, GuardDuty, the operator role, the alerting: none of it is in here. And it&amp;rsquo;s not absent by accident. The README has a section headed &amp;ldquo;what&amp;rsquo;s deliberately NOT in scope&amp;rdquo; that lists those exclusions out loud. The boundary is written down, because the boundary is the design.&lt;/p&gt;
&lt;h2 id="why-the-line-is-exactly-there"&gt;Why the line is exactly there
&lt;/h2&gt;&lt;p&gt;The reason the line sits where it does is the most useful idea in the module.&lt;/p&gt;
&lt;p&gt;Everything bootstrap excludes belongs in a &lt;em&gt;separate&lt;/em&gt; stack, applied &lt;em&gt;through the automation role bootstrap creates&lt;/em&gt;. Bootstrap&amp;rsquo;s only job is to get the account to the point where the next &lt;code&gt;tofu apply&lt;/code&gt; can run properly: somewhere to store state, and an identity to run as. Once those two things exist, hardening the account isn&amp;rsquo;t a special bootstrapping act. It&amp;rsquo;s just another apply, done the normal way: in CI, reviewed, versioned, deployed through the role.&lt;/p&gt;
&lt;p&gt;So the account baseline doesn&amp;rsquo;t need to be bundled into the bootstrap. It needs to be &lt;em&gt;downstream&lt;/em&gt; of it. Bootstrap builds the on-ramp; it doesn&amp;rsquo;t also have to be the motorway.&lt;/p&gt;
&lt;h2 id="a-narrow-module-stays-re-runnable"&gt;A narrow module stays re-runnable
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a practical payoff to the narrowness, and it&amp;rsquo;s about fear.&lt;/p&gt;
&lt;p&gt;Bootstrap is the one stack that &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; be applied through CI, because it&amp;rsquo;s what creates the CI identity in the first place. It runs locally, by a human, rarely. That&amp;rsquo;s exactly the kind of operation you want to be small, boring, and safe to repeat.&lt;/p&gt;
&lt;p&gt;A bootstrap module that also did account hardening would be a large, stateful thing managing dozens of resources. Re-running it would be a held-breath operation. Keeping it to three concerns keeps it the opposite: a small stack you can read top to bottom, re-run without anxiety, and reason about completely. The narrowness isn&amp;rsquo;t minimalism for its own sake. It&amp;rsquo;s what keeps the one human-applied stack trustworthy.&lt;/p&gt;
&lt;h2 id="the-boundary-is-the-feature"&gt;The boundary is the feature
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s tempting to judge a module by how much it does. A bootstrap module is the case where that&amp;rsquo;s exactly backwards. Its value is in how cleanly it stops.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; does the bare minimum to make an account ready for the next apply, writes down everything it refuses to do, and hands off to a downstream stack for all of it. The next post follows the trickiest of its three jobs: the state backend has a &lt;a class="link" href="https://phpboyscout.uk/the-chicken-and-egg-of-remote-state/" &gt;genuine chicken-and-egg problem&lt;/a&gt;, because it has to store Terraform state in a bucket Terraform hasn&amp;rsquo;t created yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A fresh AWS account needs a long list of things before it&amp;rsquo;s safe, and the obvious move is one big module that does the lot. &lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; deliberately does only three: a state backend, a CI identity, and an account-scrub config. Everything else is written down as out of scope.&lt;/p&gt;
&lt;p&gt;The boundary is the design. The excluded work belongs in a downstream stack applied through the CI role bootstrap creates, so hardening is just a normal reviewed apply rather than a bootstrapping special case. And keeping the one human-run, locally-applied stack small is what keeps it safe to re-run. A bootstrap module is judged by where it stops.&lt;/p&gt;</description></item><item><title>Two kinds of feature flag</title><link>https://phpboyscout.uk/two-kinds-of-feature-flag/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-kinds-of-feature-flag/</guid><description>&lt;img src="https://phpboyscout.uk/two-kinds-of-feature-flag/cover-two-kinds-of-feature-flag.png" alt="Featured image of post Two kinds of feature flag" /&gt;&lt;p&gt;go-tool-base has feature flags: switches that decide which built-in commands are live in a given run. rust-tool-base has those too. But it also has a second, completely separate kind of flag, and the difference between them is one of those distinctions that&amp;rsquo;s obvious the moment you see it and dangerously easy to conflate before you do. One decides what a command &lt;em&gt;does&lt;/em&gt;. The other decides whether a chunk of code is &lt;em&gt;in the binary at all&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="a-workspace-of-crates"&gt;A workspace of crates
&lt;/h2&gt;&lt;p&gt;Before the flags, the shape that makes them possible. go-tool-base is one Go module with packages under &lt;code&gt;pkg/&lt;/code&gt;. rust-tool-base is a &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;Cargo &lt;em&gt;workspace&lt;/em&gt;&lt;/a&gt; of seventeen crates: &lt;code&gt;rtb-app&lt;/code&gt;, &lt;code&gt;rtb-config&lt;/code&gt;, &lt;code&gt;rtb-cli&lt;/code&gt;, &lt;code&gt;rtb-vcs&lt;/code&gt;, &lt;code&gt;rtb-ai&lt;/code&gt;, &lt;code&gt;rtb-mcp&lt;/code&gt;, &lt;code&gt;rtb-docs&lt;/code&gt;, &lt;code&gt;rtb-telemetry&lt;/code&gt;, and so on, with an umbrella crate called &lt;code&gt;rtb&lt;/code&gt; that re-exports the public surface.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t tidiness for its own sake. Each subsystem being a separately compilable crate is what gives you a unit you can include or exclude &lt;em&gt;wholesale&lt;/em&gt;. Hold onto that, because it&amp;rsquo;s the hinge for everything below.&lt;/p&gt;
&lt;h2 id="the-flag-go-tool-base-already-has"&gt;The flag go-tool-base already has
&lt;/h2&gt;&lt;p&gt;go-tool-base has &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/tool.go#L43" target="_blank" rel="noopener"
 &gt;feature flags&lt;/a&gt;, and I&amp;rsquo;d describe them as runtime flags. A tool built on it can enable or disable built-in commands:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Disable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InitCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Enable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AiCmd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;At startup the framework resolves that set and decides which commands are reachable for this run. The &lt;code&gt;init&lt;/code&gt; command might be present in the binary but switched off; the &lt;code&gt;ai&lt;/code&gt; command might be switched on. It&amp;rsquo;s about the &lt;em&gt;user-facing surface&lt;/em&gt;: which commands exist for someone typing &lt;code&gt;--help&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base keeps this idea. A command carries a &lt;code&gt;CommandSpec&lt;/code&gt; with an optional &lt;code&gt;feature&lt;/code&gt; field, and the runtime decides whether a feature-gated command is reachable. Same purpose: shape the surface per invocation.&lt;/p&gt;
&lt;p&gt;If that were the whole story, there&amp;rsquo;d be nothing to write. The reason there&amp;rsquo;s a post is the &lt;em&gt;other&lt;/em&gt; kind of flag, which Rust makes available and Go really doesn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-flag-rust-adds"&gt;The flag Rust adds
&lt;/h2&gt;&lt;p&gt;Cargo features are a compile-time mechanism. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb/Cargo.toml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rtb&lt;/code&gt; umbrella crate declares them&lt;/a&gt; like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;cli&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ai&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;rtb-docs?/ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;vcs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;telemetry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;dep:rtb-telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;cli&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;docs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;mcp&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;credentials&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;tui&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;telemetry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;vcs&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each subsystem is an &lt;em&gt;optional&lt;/em&gt; crate dependency, and a feature switches it on. This is a different kind of switch entirely, and the difference is the whole point.&lt;/p&gt;
&lt;p&gt;A runtime flag decides what a command does &lt;em&gt;while the program runs&lt;/em&gt;. The code is in the binary either way; the flag just gates it.&lt;/p&gt;
&lt;p&gt;A Cargo feature decides what&amp;rsquo;s &lt;em&gt;in the binary in the first place&lt;/em&gt;. Build a tool without the &lt;code&gt;vcs&lt;/code&gt; feature and &lt;code&gt;rtb-vcs&lt;/code&gt; is not compiled. Its dependencies are not compiled. &lt;code&gt;gix&lt;/code&gt;, the pure-Rust Git implementation &lt;code&gt;rtb-vcs&lt;/code&gt; pulls in, roughly two and a half megabytes of it, is not compiled and not linked. It isn&amp;rsquo;t switched off in the binary. It was never in the binary. The compiler never even saw it.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s something a runtime flag cannot do, because by the time anything runs, the binary already exists with everything in it.&lt;/p&gt;
&lt;h2 id="two-axes-kept-separate"&gt;Two axes, kept separate
&lt;/h2&gt;&lt;p&gt;So rust-tool-base has two flag systems answering two genuinely different questions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cargo features&lt;/strong&gt; answer: &lt;em&gt;what is this binary made of?&lt;/em&gt; They&amp;rsquo;re decided when you build the tool, in &lt;code&gt;Cargo.toml&lt;/code&gt;. They control compilation, binary size, dependency surface, and compile time. A tool that never touches Git builds without &lt;code&gt;vcs&lt;/code&gt; and is smaller, faster to compile, and has a smaller dependency tree to audit. A tool that wants everything turns on &lt;code&gt;full&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Runtime feature flags&lt;/strong&gt; answer: &lt;em&gt;what can the user do in this run?&lt;/em&gt; They&amp;rsquo;re decided as the program starts. They control which commands appear, which paths are reachable.&lt;/p&gt;
&lt;p&gt;These could have been mashed into one mechanism, and it would have been a mistake. The app-context design notes are blunt about it: feature gating doesn&amp;rsquo;t belong on the per-command context object, because a feature-gated command &amp;ldquo;either exists or doesn&amp;rsquo;t&amp;rdquo; rather than changing its behaviour mid-run. Compile-time composition is one decision, made by the person building the tool. Runtime gating is another, made per invocation. Conflating them would mean you couldn&amp;rsquo;t reason cleanly about either.&lt;/p&gt;
&lt;h2 id="the-go-version-of-this-had-to-be-hand-built"&gt;The Go version of this had to be hand-built
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a thing Go simply lacks. I &lt;a class="link" href="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/" &gt;wrote a whole post&lt;/a&gt; about how go-tool-base keeps its optional keychain dependency out of binaries that don&amp;rsquo;t want it, using a blank import and the linker&amp;rsquo;s dead-code elimination. It works. But it was a piece of deliberate engineering for &lt;em&gt;one&lt;/em&gt; dependency, and getting it right took care.&lt;/p&gt;
&lt;p&gt;Cargo features make that same outcome a first-class, declarative thing, and not for one dependency but for every subsystem the framework has. You don&amp;rsquo;t engineer the exclusion. You name a feature and leave it off. The crate, and its whole subtree, stays out. Rust&amp;rsquo;s build system was designed for exactly this, and rust-tool-base leans on it across the entire workspace rather than hand-rolling it once.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;go-tool-base has runtime feature flags: they decide, per invocation, which built-in commands are reachable. rust-tool-base keeps that, and adds a second kind that Rust makes available.&lt;/p&gt;
&lt;p&gt;Cargo features decide what the binary is &lt;em&gt;compiled from&lt;/em&gt;. Each of the framework&amp;rsquo;s seventeen crates is an optional dependency, and a feature switched off means that crate and its entire dependency subtree are never compiled or linked. A runtime flag gates what code &lt;em&gt;does&lt;/em&gt;; a Cargo feature gates whether code &lt;em&gt;is there at all&lt;/em&gt;. Two axes, two questions, deliberately kept as separate systems.&lt;/p&gt;</description></item><item><title>forbid means forbid, until linkme needs a word</title><link>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/</guid><description>&lt;img src="https://phpboyscout.uk/forbid-means-forbid-until-linkme-needs-a-word/cover-forbid-means-forbid-until-linkme-needs-a-word.png" alt="Featured image of post forbid means forbid, until linkme needs a word" /&gt;&lt;p&gt;There&amp;rsquo;s a line at the top of every production crate in rust-tool-base that I&amp;rsquo;m quietly proud of: &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;. And there are a couple of files that have to say &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; instead. Not because I wrote anything unsafe. Because a macro did, on my behalf, and &lt;code&gt;forbid&lt;/code&gt; doesn&amp;rsquo;t care whose unsafe it is.&lt;/p&gt;
&lt;h2 id="why-forbid-and-why-it-isnt-the-whole-story"&gt;Why forbid, and why it isn&amp;rsquo;t the whole story
&lt;/h2&gt;&lt;p&gt;rust-tool-base makes a bold promise: &lt;a class="link" href="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/" &gt;no unsafe in its own code&lt;/a&gt;. The strong form of that is &lt;code&gt;forbid&lt;/code&gt;, not &lt;code&gt;deny&lt;/code&gt;. &lt;code&gt;deny(unsafe_code)&lt;/code&gt; makes unsafe a compile error that any module can quietly re-permit with its own &lt;code&gt;#[allow]&lt;/code&gt;. &lt;code&gt;forbid&lt;/code&gt; can&amp;rsquo;t be overridden from inside the crate at all. That&amp;rsquo;s the appeal: nobody gets to wave unsafe through in a hurry.&lt;/p&gt;
&lt;p&gt;So the workspace lint sits at &lt;code&gt;deny&lt;/code&gt;, and every production &lt;code&gt;lib.rs&lt;/code&gt; then tightens it to &lt;code&gt;forbid&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Cargo.toml&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rust&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;unsafe_code&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deny&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// crates/rtb-error/src/lib.rs
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Why &lt;code&gt;deny&lt;/code&gt; at the workspace but &lt;code&gt;forbid&lt;/code&gt; in each crate? Because &lt;code&gt;deny&lt;/code&gt; leaves an escape hatch open for the rare file that genuinely needs one, while &lt;code&gt;forbid&lt;/code&gt; slams it shut everywhere it can. Almost every file gets &lt;code&gt;forbid&lt;/code&gt;. A tiny number need the hatch.&lt;/p&gt;
&lt;h2 id="the-files-that-need-the-hatch"&gt;The files that need the hatch
&lt;/h2&gt;&lt;p&gt;The command and provider registries use &lt;code&gt;linkme&lt;/code&gt;&amp;rsquo;s &lt;code&gt;distributed_slice&lt;/code&gt; so backends can register themselves at link time, without &lt;a class="link" href="https://phpboyscout.uk/registering-commands-without-life-before-main/" &gt;life before main&lt;/a&gt;. And the &lt;code&gt;linkme&lt;/code&gt; attribute expands to code carrying a &lt;code&gt;#[link_section]&lt;/code&gt;, which the &lt;code&gt;unsafe_code&lt;/code&gt; lint counts as unsafe. So any file using the attribute, whether it declares a slice or registers into one, can&amp;rsquo;t live under &lt;code&gt;forbid&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the Gitea release backend doing exactly that, from &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-vcs/src/gitea.rs#L21" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-vcs/src/gitea.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![allow(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Link-time registration entry.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(RELEASE_PROVIDERS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;__register_gitea&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderRegistration&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RegisteredProvider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;source_type&lt;/span&gt;: &lt;span class="s"&gt;&amp;#34;gitea&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;: &lt;span class="nc"&gt;factory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ProviderFactory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; isn&amp;rsquo;t there because the backend does anything dangerous. It&amp;rsquo;s there because the registration macro emits a &lt;code&gt;#[link_section]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; would, correctly by its own rules, refuse to compile the file.&lt;/p&gt;
&lt;h2 id="where-that-leaves-the-promise"&gt;Where that leaves the promise
&lt;/h2&gt;&lt;p&gt;The guarantee survives, with an exception you can point at. Every production crate forbids unsafe outright. The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L37-43" target="_blank" rel="noopener"
 &gt;workspace&lt;/a&gt; sits one notch looser at &lt;code&gt;deny&lt;/code&gt;, precisely so the handful of files that use &lt;code&gt;linkme&lt;/code&gt; (and a couple of test files that need Rust 2024&amp;rsquo;s unsafe env mutation) can open a narrow, module-scoped &lt;code&gt;#![allow(unsafe_code)]&lt;/code&gt; with a written reason. The absolutist rule met a macro that writes a &lt;code&gt;link_section&lt;/code&gt; for you. The answer wasn&amp;rsquo;t to drop the rule, it was to keep &lt;code&gt;forbid&lt;/code&gt; everywhere it can hold and clearly label the one or two spots where it can&amp;rsquo;t.&lt;/p&gt;</description></item><item><title>A framework that contains no unsafe</title><link>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</link><pubDate>Tue, 28 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-framework-that-contains-no-unsafe/</guid><description>&lt;img src="https://phpboyscout.uk/a-framework-that-contains-no-unsafe/cover-a-framework-that-contains-no-unsafe.png" alt="Featured image of post A framework that contains no unsafe" /&gt;&lt;p&gt;&amp;ldquo;It&amp;rsquo;s written in Rust&amp;rdquo; gets thrown around as if it were a memory-safety guarantee. It mostly isn&amp;rsquo;t. Rust is memory-safe by &lt;em&gt;default&lt;/em&gt;, which is a wonderful thing, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists precisely so any crate, any module, can step outside that default when it needs to. So &amp;ldquo;written in Rust&amp;rdquo; really means &amp;ldquo;mostly safe, probably&amp;rdquo;. rust-tool-base makes the stronger claim about its own code, and gets the compiler to enforce it.&lt;/p&gt;
&lt;h2 id="safe-by-default-is-not-the-same-as-safe"&gt;Safe by default is not the same as safe
&lt;/h2&gt;&lt;p&gt;People reach for Rust because of memory safety, and the reputation is earned. Write ordinary Rust and the compiler will not let you have a use-after-free, a data race, or a buffer overrun. That&amp;rsquo;s the default, and it&amp;rsquo;s a very good default.&lt;/p&gt;
&lt;p&gt;But it&amp;rsquo;s a default, and defaults can be turned off. Rust has an &lt;code&gt;unsafe&lt;/code&gt; keyword precisely so that, when you genuinely need to, you can dereference a raw pointer, call into C, or tell the compiler you&amp;rsquo;ve upheld an invariant it can&amp;rsquo;t check itself. Inside an &lt;code&gt;unsafe&lt;/code&gt; block, the guarantees are yours to maintain, not the compiler&amp;rsquo;s to enforce.&lt;/p&gt;
&lt;p&gt;That keyword has to exist. Some of the most foundational crates in the ecosystem are built on it, carefully. But it means a fact worth being precise about: a project being &amp;ldquo;written in Rust&amp;rdquo; tells you its code is &lt;em&gt;mostly&lt;/em&gt; safe. It does not tell you the project&amp;rsquo;s own code contains &lt;em&gt;no&lt;/em&gt; &lt;code&gt;unsafe&lt;/code&gt;. Those are different claims, and only the second one is a guarantee.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes the second claim about its own code, and has the compiler back it up.&lt;/p&gt;
&lt;h2 id="forbid-not-just-deny"&gt;&lt;code&gt;forbid&lt;/code&gt;, not just &lt;code&gt;deny&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The mechanism is one line at the top of every crate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#![forbid(unsafe_code)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;unsafe_code&lt;/code&gt; is a lint, and Rust lints have levels. The interesting choice is &lt;code&gt;forbid&lt;/code&gt; rather than &lt;code&gt;deny&lt;/code&gt;, because the two are not the same strength.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;deny&lt;/code&gt; makes the lint an error. But it&amp;rsquo;s an error a &lt;em&gt;downstream module can locally override&lt;/em&gt;. Anyone can write &lt;code&gt;#[allow(unsafe_code)]&lt;/code&gt; on a function or a block and the &lt;code&gt;deny&lt;/code&gt; is lifted right there. As a policy, &lt;code&gt;deny&lt;/code&gt; is &amp;ldquo;don&amp;rsquo;t do this unless you really mean to&amp;rdquo;, and &amp;ldquo;unless you really mean to&amp;rdquo; is a door.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;forbid&lt;/code&gt; is the strict one. It makes the lint an error &lt;em&gt;and&lt;/em&gt; it makes that error impossible to override from inside the crate. A module cannot &lt;code&gt;#[allow]&lt;/code&gt; its way back out. Once a crate root says &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, there&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; anywhere in that crate, and no local exception can be carved out. The compiler simply refuses.&lt;/p&gt;
&lt;p&gt;So every rust-tool-base crate that ships in a built tool forbids &lt;code&gt;unsafe&lt;/code&gt; at its root. Not &amp;ldquo;discourages&amp;rdquo;. Cannot contain it.&lt;/p&gt;
&lt;h2 id="the-one-subtlety"&gt;The one subtlety
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a wrinkle, and it&amp;rsquo;s worth showing rather than hiding, because it&amp;rsquo;s where the design got specific.&lt;/p&gt;
&lt;p&gt;The workspace sets &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/Cargo.toml#L43" target="_blank" rel="noopener"
 &gt;&lt;code&gt;unsafe_code = &amp;quot;deny&amp;quot;&lt;/code&gt;&lt;/a&gt; as the baseline for &lt;em&gt;everything&lt;/em&gt;, including test files. But test code occasionally has a real need for &lt;code&gt;unsafe&lt;/code&gt;. In the 2024 edition, &lt;code&gt;std::env::set_var&lt;/code&gt; became &lt;code&gt;unsafe&lt;/code&gt;, because mutating the process environment isn&amp;rsquo;t thread-safe, and a test that exercises environment-driven configuration has to call it.&lt;/p&gt;
&lt;p&gt;So the split is deliberate. The workspace-wide level is &lt;code&gt;deny&lt;/code&gt;, which a test file can locally &lt;code&gt;#[allow]&lt;/code&gt; when it genuinely needs that one environment call. But every production &lt;code&gt;lib.rs&lt;/code&gt; and &lt;code&gt;main.rs&lt;/code&gt; additionally carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt; cannot be relaxed. Test scaffolding gets a controlled, visible exception for a specific standard-library call. Shipping code gets none. The guarantee that matters, &amp;ldquo;the code in the binary contains no &lt;code&gt;unsafe&lt;/code&gt;&amp;rdquo;, holds, and the place it&amp;rsquo;s slightly loosened is exactly the place that never reaches a user.&lt;/p&gt;
&lt;h2 id="what-the-guarantee-is-actually-worth"&gt;What the guarantee is actually worth
&lt;/h2&gt;&lt;p&gt;Two things, one for users and one for reviewers.&lt;/p&gt;
&lt;p&gt;For users: an entire family of bug is ruled out of first-party code mechanically. Use-after-free, double-free, data races on shared memory, reading off the end of a buffer. These are the classic memory-safety vulnerabilities, and in a crate that forbids &lt;code&gt;unsafe&lt;/code&gt; they cannot originate, because the constructs that produce them cannot be written. That&amp;rsquo;s not careful coding. It&amp;rsquo;s the compiler refusing to build anything else.&lt;/p&gt;
&lt;p&gt;For reviewers: the cost of an &lt;code&gt;unsafe&lt;/code&gt; block is mostly the review burden it carries. Every one is a spot where a human has to check, by hand, that an invariant holds, and has to re-check it whenever nearby code changes. A crate that forbids &lt;code&gt;unsafe&lt;/code&gt; has zero of those. There&amp;rsquo;s no &lt;code&gt;unsafe&lt;/code&gt; block to audit, ever, because the compiler guarantees there isn&amp;rsquo;t one.&lt;/p&gt;
&lt;p&gt;The promise has a boundary. It covers rust-tool-base&amp;rsquo;s &lt;em&gt;own&lt;/em&gt; code; its dependencies are another matter, and some of them do contain &lt;code&gt;unsafe&lt;/code&gt;, correctly. Keeping that side honest is a different job, done by &lt;a class="link" href="https://phpboyscout.uk/waivers-with-an-expiry-date/" &gt;vetting the dependency tree and gating it in CI&lt;/a&gt;. Within first-party code, though, the guarantee is real, and there&amp;rsquo;s no Go equivalent to it. Go has an &lt;code&gt;unsafe&lt;/code&gt; package, but nothing that lets a codebase prove, to the compiler, that it never touches it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Rust is memory-safe by default, but the &lt;code&gt;unsafe&lt;/code&gt; keyword exists so that default can be set aside. &amp;ldquo;Written in Rust&amp;rdquo; therefore does not by itself mean a project&amp;rsquo;s own code contains no &lt;code&gt;unsafe&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base makes that the stronger claim. Every crate root carries &lt;code&gt;#![forbid(unsafe_code)]&lt;/code&gt;, and &lt;code&gt;forbid&lt;/code&gt;, unlike &lt;code&gt;deny&lt;/code&gt;, cannot be overridden from inside the crate. Test files get a narrow, visible &lt;code&gt;deny&lt;/code&gt;-level exception for the one standard-library call that needs it; shipping code gets none. The payoff is a whole class of memory-safety bug ruled out of first-party code by construction, and not one &lt;code&gt;unsafe&lt;/code&gt; block left for a reviewer to audit.&lt;/p&gt;</description></item><item><title>Reloading config without a restart</title><link>https://phpboyscout.uk/reloading-config-without-a-restart/</link><pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/reloading-config-without-a-restart/</guid><description>&lt;img src="https://phpboyscout.uk/reloading-config-without-a-restart/cover-reloading-config-without-a-restart.png" alt="Featured image of post Reloading config without a restart" /&gt;&lt;p&gt;A config file changes. Someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out? For most processes the answer is blunt: it doesn&amp;rsquo;t, until you restart it. For a short-lived CLI that&amp;rsquo;s completely fine. For a long-running service, &amp;ldquo;just restart it&amp;rdquo; is a much bigger ask than it sounds.&lt;/p&gt;
&lt;h2 id="the-default-answer-is-a-restart"&gt;The default answer is a restart
&lt;/h2&gt;&lt;p&gt;Configuration lives in a file. The file changes: someone edits a setting, rotates a credential, flips a feature flag. How does the running process find out?&lt;/p&gt;
&lt;p&gt;Overwhelmingly, the answer is that it doesn&amp;rsquo;t. A process reads its config once, at startup, and that snapshot is frozen for the life of the process. Change the file and nothing happens until you restart, at which point a fresh process reads the fresh file.&lt;/p&gt;
&lt;p&gt;For a short-lived CLI invocation that&amp;rsquo;s completely fine. It reads config, does its job, exits, and the next invocation reads whatever the file says then. But the same frameworks are also used to build long-running services, and for a service &amp;ldquo;just restart it&amp;rdquo; is not the small thing it sounds like.&lt;/p&gt;
&lt;h2 id="what-a-restart-actually-costs"&gt;What a restart actually costs
&lt;/h2&gt;&lt;p&gt;Restarting a long-running service means every open connection drops. Any in-flight request is lost, or has to be retried by whoever sent it. Caches that took real time to warm are cold again. There&amp;rsquo;s a window, short but real, where the service simply isn&amp;rsquo;t serving.&lt;/p&gt;
&lt;p&gt;If the thing you changed was a log level, or a feature flag, or a timeout, you&amp;rsquo;ve paid a disruption wildly out of proportion to the change. And the calculation only gets worse as the service gets more important, because the services you least want to bounce on a whim are exactly the ones that matter most.&lt;/p&gt;
&lt;h2 id="hot-reload-re-read-in-place"&gt;Hot-reload: re-read in place
&lt;/h2&gt;&lt;p&gt;Hot-reload is the alternative, and both go-tool-base and rust-tool-base support it.&lt;/p&gt;
&lt;p&gt;The process doesn&amp;rsquo;t read config once and freeze it. It &lt;em&gt;watches&lt;/em&gt; the config file. When the file changes, it re-reads it, re-applies it, and carries on running. No new process, no dropped connections, no cold start. The change lands in the live process.&lt;/p&gt;
&lt;p&gt;The shape is the same in both frameworks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A file watcher notices the config file changed. Underneath, this is the operating system&amp;rsquo;s own file-notification facility, &lt;code&gt;inotify&lt;/code&gt; on Linux and its equivalents elsewhere. rust-tool-base reaches it through the &lt;code&gt;notify&lt;/code&gt; crate; go-tool-base, through the watcher built into Viper.&lt;/li&gt;
&lt;li&gt;A debounce step waits for the writes to settle. Saving a file is often several separate operations, and you don&amp;rsquo;t want to reload three times for one edit.&lt;/li&gt;
&lt;li&gt;The config is re-parsed from disk.&lt;/li&gt;
&lt;li&gt;The new config is swapped in atomically.&lt;/li&gt;
&lt;li&gt;Observers are notified, so the subsystems that care can react.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps four and five are the ones worth slowing down on, because they&amp;rsquo;re where a naive hot-reload quietly goes wrong.&lt;/p&gt;
&lt;h2 id="the-two-details-that-make-it-safe"&gt;The two details that make it safe
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;The atomic swap.&lt;/strong&gt; You do not mutate the live config object in place. A reader on another thread, partway through reading it, would see a torn mix of old and new values, and that&amp;rsquo;s a genuinely nasty class of bug. Instead the process builds a &lt;em&gt;new&lt;/em&gt;, complete config value and swaps the pointer to it in a single atomic operation. Any reader sees either the entire old config or the entire new one, never a blend. rust-tool-base does this with &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-config/src/watch.rs" target="_blank" rel="noopener"
 &gt;&lt;code&gt;arc-swap&lt;/code&gt;&lt;/a&gt;; go-tool-base does the equivalent. Reads stay cheap and lock-free, and an update is one pointer swap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The observer notification.&lt;/strong&gt; Re-reading the file isn&amp;rsquo;t the end of the job. Some subsystems have to &lt;em&gt;do something&lt;/em&gt; when config changes: a connection pool resizes, a logger changes level, a rate limiter takes a new ceiling. So a hot-reload system has to let those subsystems subscribe. rust-tool-base hands observers a &lt;code&gt;watch::Receiver&lt;/code&gt;, a channel that always holds the latest value; go-tool-base exposes an &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/config/observer.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Observable&lt;/code&gt; interface&lt;/a&gt;. A subsystem subscribes once and reacts every time config changes, for the life of the process.&lt;/p&gt;
&lt;h2 id="where-this-earns-its-keep-a-kubernetes-pod"&gt;Where this earns its keep: a Kubernetes pod
&lt;/h2&gt;&lt;p&gt;Hot-reload is a nicety on a developer&amp;rsquo;s laptop. Inside a Kubernetes pod it becomes genuinely valuable, and the reason is a neat fit between how Kubernetes delivers config and how a file watcher works.&lt;/p&gt;
&lt;p&gt;In Kubernetes you don&amp;rsquo;t usually bake configuration into the container image. It lives in ConfigMap and Secret objects, and the clean way to consume them is to &lt;em&gt;mount them as volumes&lt;/em&gt;. Mount a ConfigMap as a volume and each key becomes a file in the pod&amp;rsquo;s filesystem.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part that connects to everything above. When you update that ConfigMap or Secret, Kubernetes does not restart your pod. The kubelet notices the object changed and rewrites the projected files inside the still-running pod. The files on disk change underneath a process that never stopped.&lt;/p&gt;
&lt;p&gt;That file rewrite is exactly the event a hot-reload watcher exists to catch. So the whole chain becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You &lt;code&gt;kubectl apply&lt;/code&gt; an updated ConfigMap, or rotate a Secret.&lt;/li&gt;
&lt;li&gt;The kubelet updates the projected files inside the pod.&lt;/li&gt;
&lt;li&gt;The framework&amp;rsquo;s file watcher sees the write.&lt;/li&gt;
&lt;li&gt;The config is re-parsed, swapped in atomically, and observers are notified.&lt;/li&gt;
&lt;li&gt;The new configuration is live, and the pod never cycled.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&amp;rsquo;ve changed a running service, in a running pod, with no rollout, nothing terminated and recreated, no dropped traffic. Rotate a database credential, raise a log level to debug an incident in progress, flip a feature flag: all of it live. For a service where a restart is the very thing you&amp;rsquo;re trying hard to avoid, the kind of &lt;a class="link" href="https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/" &gt;long-running service&lt;/a&gt; these frameworks are built for, that&amp;rsquo;s the difference between a config change being routine and being an event.&lt;/p&gt;
&lt;h2 id="the-caveats"&gt;The caveats
&lt;/h2&gt;&lt;p&gt;Two things, so this doesn&amp;rsquo;t read as magic.&lt;/p&gt;
&lt;p&gt;First, not everything can be hot-reloaded. Some configuration genuinely needs a restart: the port a server binds to, the size of a thread pool, anything wired up exactly once at process start. Hot-reload covers the large category of settings a subsystem can re-read and re-apply; it doesn&amp;rsquo;t abolish restarts. A config system worth its salt is clear about which settings are live and which are not.&lt;/p&gt;
&lt;p&gt;Second, a Kubernetes gotcha that catches people out. The in-place file update happens for ConfigMaps and Secrets mounted as &lt;em&gt;volumes&lt;/em&gt;. Consume the same ConfigMap as &lt;em&gt;environment variables&lt;/em&gt; instead, and those are fixed when the container starts and never update, short of a restart. If you want hot-reload in a pod, mount config and secrets as files, not env vars. And even with volumes the update isn&amp;rsquo;t instant: the kubelet syncs on a period, around a minute by default, so a reload is &amp;ldquo;within a minute or so&amp;rdquo;, not &amp;ldquo;the moment you hit apply&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A config file changes, and the default way to pick it up is to restart the process. For a long-running service that restart costs dropped connections, lost work and a cold start, often for a change as small as a log level.&lt;/p&gt;
&lt;p&gt;go-tool-base and rust-tool-base both support hot-reload instead: a file watcher catches the change, the config is re-parsed and swapped in atomically so no reader sees torn state, and observers are notified so subsystems can react, all in a live process. The setting where it pays off most is a Kubernetes pod, where ConfigMaps and Secrets mounted as volumes are rewritten in place by the kubelet and the watcher catches that write directly. Mount them as volumes rather than env vars, allow for the kubelet&amp;rsquo;s sync delay, accept that some settings still need a restart, and within those limits &amp;ldquo;the config changed&amp;rdquo; stops meaning &amp;ldquo;cycle the pod&amp;rdquo;.&lt;/p&gt;</description></item><item><title>A signing key needs somewhere to live</title><link>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/</guid><description>&lt;img src="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/cover-a-signing-key-needs-somewhere-to-live.png" alt="Featured image of post A signing key needs somewhere to live" /&gt;&lt;p&gt;I left a door open a couple of posts ago, and it&amp;rsquo;s been quietly bothering me ever since. When I wrote about &lt;a class="link" href="https://phpboyscout.uk/verifying-your-own-downloads/" &gt;verifying your own downloads&lt;/a&gt;, I was honest that a checksum sitting next to the binary only catches accidents. Anyone who can compromise the release platform can swap the binary and the checksum together, and the tool will happily verify one fake against the other.&lt;/p&gt;
&lt;p&gt;Closing that gap needs a signature. And a signature, it turns out, needs a surprising amount of infrastructure standing behind it. This is the first post about building that.&lt;/p&gt;
&lt;h2 id="the-door-the-last-post-left-open"&gt;The door the last post left open
&lt;/h2&gt;&lt;p&gt;A while back I wrote about verifying your own downloads: go-tool-base&amp;rsquo;s self-update command now checks the SHA-256 of every binary it downloads against the release&amp;rsquo;s published &lt;code&gt;checksums.txt&lt;/code&gt; before installing it.&lt;/p&gt;
&lt;p&gt;That post was honest about its own ceiling. A checksum file hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes shares a trust root with that binary. Both come from the same release, on the same platform. Corruption, truncation, a CDN serving a stale object: a same-origin checksum catches all of those, because they&amp;rsquo;re accidents and the checksum wasn&amp;rsquo;t part of the accident. What it can&amp;rsquo;t catch is an attacker who&amp;rsquo;s compromised the release platform itself. Someone who can replace the binary can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and the tool will cheerfully verify the malicious download against the malicious checksum and call it good.&lt;/p&gt;
&lt;p&gt;The post named the fix and then deferred it: a signature whose trust root sits somewhere the release platform can&amp;rsquo;t reach. &amp;ldquo;That&amp;rsquo;s the next phase of this work.&amp;rdquo; This series is that phase.&lt;/p&gt;
&lt;h2 id="what-a-signature-actually-needs"&gt;What a signature actually needs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being precise about why a signature helps where a checksum doesn&amp;rsquo;t, because it&amp;rsquo;s easy to wave the word &amp;ldquo;signature&amp;rdquo; around and assume it settles everything.&lt;/p&gt;
&lt;p&gt;A signature closes the gap only under two conditions. The verifying key, the public half, must reach the user by a path the release platform doesn&amp;rsquo;t control. And the signing key, the private half, must live somewhere the release platform can&amp;rsquo;t reach.&lt;/p&gt;
&lt;p&gt;The second condition is the one people skip. If the signing key sits in the same CI system that builds the release, you&amp;rsquo;ve gained almost nothing. An attacker who owns the CI owns the key, and a key they own will sign whatever they hand it. The signature verifies perfectly and means precisely nothing. A signature is only worth the distance between the signing key and the thing being signed. Put them in the same place and the distance is zero.&lt;/p&gt;
&lt;p&gt;So the signing key has to live in a different security domain from the release pipeline. Not a different folder. A different account, with a different blast radius, that the release platform has no standing access to.&lt;/p&gt;
&lt;h2 id="just-sign-the-binary-is-not-a-small-feature"&gt;&amp;ldquo;Just sign the binary&amp;rdquo; is not a small feature
&lt;/h2&gt;&lt;p&gt;That reframes a line item that sounds tiny. &amp;ldquo;Sign the release binary&amp;rdquo; unpacks into a list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;there must be a private signing key;&lt;/li&gt;
&lt;li&gt;it must live outside the release platform, in its own security domain;&lt;/li&gt;
&lt;li&gt;it must be access-controlled, audited, and protected from exfiltration;&lt;/li&gt;
&lt;li&gt;only the release pipeline may ask it to sign, and only by proving a short-lived, federated identity, never by holding a copy of the key.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s not a feature you bolt onto a CLI. That&amp;rsquo;s infrastructure.&lt;/p&gt;
&lt;p&gt;The shape of it: a cloud account, with the key held in a managed key service so the private key material never exists as a file on a disk that anyone, me included, can copy. The release pipeline authenticates to that account as itself, briefly, and asks the key service to produce a signature. The key never moves.&lt;/p&gt;
&lt;p&gt;But an account you&amp;rsquo;re going to trust with a signing key is itself something you have to get right first. An account with a weak baseline, no audit trail, and long-lived credentials lying around is not a safe home for the most security-sensitive key in the whole system. Before the key can move in, the house has to be built and the locks have to actually work.&lt;/p&gt;
&lt;h2 id="what-this-series-builds"&gt;What this series builds
&lt;/h2&gt;&lt;p&gt;So this turned into a rather longer project than &amp;ldquo;add a signature&amp;rdquo;, and the series follows it in order.&lt;/p&gt;
&lt;p&gt;It starts with bootstrapping a fresh AWS account: the deliberately minimal first &lt;code&gt;tofu apply&lt;/code&gt;, and the remote state backend that has a genuine chicken-and-egg problem. Then the credential question, which is the heart of it: how a CI pipeline deploys to AWS with no stored access key at all. Then hardening the account, so it&amp;rsquo;s genuinely safe to hold something valuable. Then the discipline of deploying changes to it: plans reviewed before they&amp;rsquo;re applied. Then the shared tooling that makes all of it repeatable.&lt;/p&gt;
&lt;p&gt;Every one of those pieces exists for the same reason. The signing key needs somewhere to live, and somewhere safe is not a default you&amp;rsquo;re handed. It&amp;rsquo;s a thing you build, deliberately, before you have anything worth protecting in it.&lt;/p&gt;
&lt;p&gt;The series ends where the verifying-downloads post pointed: a signing service whose key the release platform can&amp;rsquo;t touch, so a self-updating tool can finally verify that the binary it&amp;rsquo;s about to become is genuinely the one I published.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s self-update verifies downloads against a checksum, and a same-origin checksum stops accidents but not a compromise of the release platform. The fix is a signature, and a signature is only worth the distance between its signing key and the release pipeline.&lt;/p&gt;
&lt;p&gt;Holding that key safely means a private key that never leaves a managed key service, in a separate cloud account, reached only by a short-lived federated identity. That&amp;rsquo;s infrastructure, and a safe account is something you build before you trust it with anything. The rest of this series builds it, piece by piece, right up to the signing service itself.&lt;/p&gt;</description></item><item><title>Waivers with an expiry date</title><link>https://phpboyscout.uk/waivers-with-an-expiry-date/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/waivers-with-an-expiry-date/</guid><description>&lt;img src="https://phpboyscout.uk/waivers-with-an-expiry-date/cover-waivers-with-an-expiry-date.png" alt="Featured image of post Waivers with an expiry date" /&gt;&lt;p&gt;A vulnerability scanner gives you a yes or a no. Is there a known advisory on a path you actually use? Yes, or no. That&amp;rsquo;s genuinely useful, and you should run one. But it&amp;rsquo;s a snapshot, taken on the day you ask, and supply-chain risk in a framework is a bigger and more ongoing thing than a single yes-or-no can capture.&lt;/p&gt;
&lt;p&gt;So rust-tool-base treats its whole dependency tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, not something to scan and forget.&lt;/p&gt;
&lt;h2 id="a-scanner-answers-one-question"&gt;A scanner answers one question
&lt;/h2&gt;&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/every-finding-was-the-same-shape/" &gt;had go-tool-base security-audited&lt;/a&gt;, part of the routine was running a vulnerability scanner over the dependencies. Go has a good one. It looks at your dependency graph, cross-references known advisories, and tells you whether any of them reach code you actually call.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s useful and you should do it. But notice the shape of what it gives back: essentially a yes or a no. Either there&amp;rsquo;s a known vulnerability on a reachable path or there isn&amp;rsquo;t. It answers one question, on the day you ask it.&lt;/p&gt;
&lt;p&gt;Supply-chain risk in a framework is broader than that one question, because a framework drags its entire dependency tree into every tool built on it. rust-tool-base treats the whole tree as something to have a &lt;em&gt;policy&lt;/em&gt; about, and the tool for that is &lt;code&gt;cargo-deny&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="a-gate-not-a-scan"&gt;A gate, not a scan
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;cargo-deny&lt;/code&gt; reads a &lt;code&gt;deny.toml&lt;/code&gt; and checks the dependency graph against four kinds of rule.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Licences.&lt;/strong&gt; There&amp;rsquo;s an allowlist: MIT, Apache-2.0, the BSD variants, ISC, a handful of others. Every transitive crate&amp;rsquo;s licence has to be on it. A dependency that pulls in something copyleft, or something with no licence at all, fails the build. You find out the first time it enters the tree, not during a release scramble when someone finally reads the legal implications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Advisories.&lt;/strong&gt; It checks the RustSec advisory database, and yanked crates are set to &lt;code&gt;deny&lt;/code&gt;, so a dependency that&amp;rsquo;s been pulled from the registry stops CI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bans.&lt;/strong&gt; Wildcard version requirements (&lt;code&gt;version = &amp;quot;*&amp;quot;&lt;/code&gt;) are denied outright, because a dependency that floats to whatever&amp;rsquo;s newest is a supply-chain hole by construction. Duplicate versions of the same crate get surfaced too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sources.&lt;/strong&gt; Crates may only come from the official registry. An unknown registry or a stray git dependency is denied. Nothing sneaks in from a URL.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a gate. It encodes, as rules in a file, what the project will and won&amp;rsquo;t accept into its dependency tree, and it enforces them on every build instead of once an audit.&lt;/p&gt;
&lt;h2 id="the-honest-part-is-the-waiver-list"&gt;The honest part is the waiver list
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing every real project runs into. Sooner or later there&amp;rsquo;s an advisory you genuinely can&amp;rsquo;t fix this week. It&amp;rsquo;s against a crate three levels down your tree. The fix needs an upstream release that hasn&amp;rsquo;t happened. The crate is scheduled to be reworked two milestones from now anyway. The gate is going to fail, and the work to satisfy it honestly isn&amp;rsquo;t available to you yet.&lt;/p&gt;
&lt;p&gt;The lazy response is a blanket ignore: silence the advisory, move on, forget. Now your gate has a hole in it that nobody remembers opening.&lt;/p&gt;
&lt;p&gt;rust-tool-base&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/deny.toml#L14" target="_blank" rel="noopener"
 &gt;&lt;code&gt;deny.toml&lt;/code&gt;&lt;/a&gt; does something better. Every waiver in the &lt;code&gt;ignore&lt;/code&gt; list is a documented record. Each one carries a comment that names the crate, traces the &lt;em&gt;exact dependency path&lt;/em&gt; that reaches it, gives the reason, and names the condition that lifts it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;ignore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `instant` - reached via async-openai -&amp;gt; backoff -&amp;gt; rtb-ai (v0.3).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0384&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# `paste` - reached via ratatui -&amp;gt; rtb-docs (v0.2) / rtb-tui (v0.4).&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2024-0436&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The file states the policy out loud: &amp;ldquo;Every waiver points at a deferred stub crate that will be reworked before its ship milestone. Lift each waiver when the owning crate lands its v0.1.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Some waivers go further and carry a structured reason field, so the &lt;em&gt;why&lt;/em&gt; travels with the entry rather than living only in a comment above it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;RUSTSEC-2025-0140&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;gix-date via gix is a stub dependency; rtb-vcs v0.5 will upgrade&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that list and you don&amp;rsquo;t see a project that quietly stopped caring about seven advisories. You see seven advisories the project knows about, can trace, and has tied to a specific milestone. The waiver has an expiry condition. When &lt;code&gt;rtb-vcs&lt;/code&gt; reaches v0.5, that &lt;code&gt;gix&lt;/code&gt; entry is meant to come out, and the comment is the reminder that it should.&lt;/p&gt;
&lt;h2 id="why-this-is-the-bit-to-copy"&gt;Why this is the bit to copy
&lt;/h2&gt;&lt;p&gt;A gate that can&amp;rsquo;t be relaxed is a gate people route around. They&amp;rsquo;ll find the broadest possible ignore and use it, because the alternative is being blocked on someone else&amp;rsquo;s release. The pressure to do that is real, and it&amp;rsquo;s not unreasonable.&lt;/p&gt;
&lt;p&gt;So the design that actually holds up isn&amp;rsquo;t a stricter gate. It&amp;rsquo;s a gate with an honest, structured escape hatch: you &lt;em&gt;can&lt;/em&gt; waive an advisory, but a waiver costs you a documented record with a dependency path and an expiry condition. That price is small enough that nobody routes around it, and high enough that waivers don&amp;rsquo;t accumulate silently. The &lt;code&gt;ignore&lt;/code&gt; list stays readable, and every line in it is something you could defend out loud.&lt;/p&gt;
&lt;p&gt;Supply-chain hygiene framed this way isn&amp;rsquo;t an audit you survive once a year. It&amp;rsquo;s bookkeeping: a ledger of what you accepted, why, and when each exception is due to close. Which, now I write it down, is just the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;Boy Scout rule&lt;/a&gt; again, pointed at a dependency tree. Leave it tidier than you found it, and write down the bits you couldn&amp;rsquo;t tidy yet.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;A vulnerability scanner answers one question on one day. &lt;code&gt;cargo-deny&lt;/code&gt; is a standing policy gate: licences against an allowlist, advisories and yanked crates denied, wildcard versions banned, sources restricted to the official registry, enforced on every build.&lt;/p&gt;
&lt;p&gt;The part of rust-tool-base&amp;rsquo;s setup worth copying is the waiver list. Every advisory that can&amp;rsquo;t be fixed yet is recorded with its crate, its dependency path, its reason and the milestone that removes it. A waiver is a dated note, not a shrug, and that&amp;rsquo;s what keeps the gate honest enough that nobody actually wants to bypass it.&lt;/p&gt;</description></item><item><title>A builder that won't compile if you forget a field</title><link>https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/</guid><description>&lt;img src="https://phpboyscout.uk/a-builder-that-wont-compile-if-you-forget-a-field/cover-a-builder-that-wont-compile-if-you-forget-a-field.png" alt="Featured image of post A builder that won't compile if you forget a field" /&gt;&lt;p&gt;go-tool-base configures things with functional options, and if you forget a required one, the best case is a runtime failure and the worst case is an empty value sailing silently into everything downstream. Most builder patterns share the same hole. rust-tool-base closes it in a way I find genuinely delightful: the &lt;code&gt;.build()&lt;/code&gt; method simply doesn&amp;rsquo;t &lt;em&gt;exist&lt;/em&gt; until you&amp;rsquo;ve set every required field.&lt;/p&gt;
&lt;h2 id="when-is-a-required-field-actually-required"&gt;When is a required field actually required
&lt;/h2&gt;&lt;p&gt;Every framework has constructors with a mix of required and optional inputs. An &lt;code&gt;Application&lt;/code&gt; in rust-tool-base needs tool metadata and a version. It optionally takes a custom config type, extra commands, feature toggles. The metadata needs a name and a summary; a description and a help channel are optional.&lt;/p&gt;
&lt;p&gt;The interesting question is &lt;em&gt;when&lt;/em&gt; &amp;ldquo;required&amp;rdquo; gets enforced. There are really only two moments available: when the program runs, or when it compiles. Most APIs pick the first without ever framing it as a choice.&lt;/p&gt;
&lt;h2 id="how-go-tool-base-does-it"&gt;How go-tool-base does it
&lt;/h2&gt;&lt;p&gt;go-tool-base uses functional options, the standard Go pattern:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;mytool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithVersion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;New&lt;/code&gt; takes a variadic list of options and applies them. It&amp;rsquo;s flexible and it reads well. But look at what the &lt;em&gt;type&lt;/em&gt; actually says. &lt;code&gt;New&lt;/code&gt; accepts zero or more options. The signature is satisfied by passing nothing at all. If &lt;code&gt;WithName&lt;/code&gt; is required, nothing in the type system knows that. Forget it and the code compiles cleanly, and you find out when the program runs, or worse, when it doesn&amp;rsquo;t visibly fail but quietly carries an empty name into everything downstream.&lt;/p&gt;
&lt;p&gt;A plain builder is no better here. &lt;code&gt;builder.name(&amp;quot;mytool&amp;quot;).build()&lt;/code&gt; and &lt;code&gt;builder.build()&lt;/code&gt; are both perfectly valid calls as far as the compiler is concerned. The builder &lt;em&gt;hopes&lt;/em&gt; you set the name. It can check at the end and return an error, but that check still happens at runtime.&lt;/p&gt;
&lt;p&gt;In every one of these the required-ness of a field is a fact that lives in documentation and in the author&amp;rsquo;s head, not in the code.&lt;/p&gt;
&lt;h2 id="typestate-putting-required-in-the-type"&gt;Typestate: putting &amp;ldquo;required&amp;rdquo; in the type
&lt;/h2&gt;&lt;p&gt;rust-tool-base builds these with &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/metadata.rs#L86" target="_blank" rel="noopener"
 &gt;&lt;code&gt;bon&lt;/code&gt;&lt;/a&gt;, and the pattern it generates is a &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;em&gt;typestate&lt;/em&gt;&lt;/a&gt; builder. The idea is that the builder&amp;rsquo;s type changes as you call it, and that type tracks which required fields you&amp;rsquo;ve set so far.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ToolMetadata&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;mytool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;my CLI tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ToolMetadata::builder()&lt;/code&gt; returns a builder in a state that records &amp;ldquo;name not set, summary not set&amp;rdquo;. Calling &lt;code&gt;.name(...)&lt;/code&gt; consumes that builder and returns a &lt;em&gt;different type&lt;/em&gt;, one whose state records &amp;ldquo;name set&amp;rdquo;. Calling &lt;code&gt;.summary(...)&lt;/code&gt; does the same for the summary.&lt;/p&gt;
&lt;p&gt;The part that matters is &lt;code&gt;.build()&lt;/code&gt;. It isn&amp;rsquo;t a method on the builder in general. It only exists on the builder type that represents &amp;ldquo;every required field has been set&amp;rdquo;. So this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ToolMetadata&lt;/span&gt;::&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;my CLI tool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;doesn&amp;rsquo;t compile. Not because a runtime check fired, but because in the state &amp;ldquo;name not set&amp;rdquo; there&amp;rsquo;s no &lt;code&gt;.build()&lt;/code&gt; method to call in the first place. The compiler stops you, and the error points straight at the missing &lt;code&gt;.name(...)&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Optional fields stay optional. You can call &lt;code&gt;.description(...)&lt;/code&gt; or skip it, and &lt;code&gt;.build()&lt;/code&gt; is reachable either way, because the description was never part of the state that gates it. The required and the optional are genuinely different in the type, which is exactly the distinction the functional-options version could only keep in a comment.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Application::builder()&lt;/code&gt; works the same way. It won&amp;rsquo;t produce an &lt;code&gt;Application&lt;/code&gt; until it has metadata and a version, and &amp;ldquo;won&amp;rsquo;t&amp;rdquo; there means the method is absent, not that a check returns &lt;code&gt;Err&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="why-the-moment-matters"&gt;Why the moment matters
&lt;/h2&gt;&lt;p&gt;Moving the check from run time to compile time changes who finds the mistake, and when.&lt;/p&gt;
&lt;p&gt;A runtime check finds it when that code path executes, which might be in a test, might be in CI, might be on a user&amp;rsquo;s machine at the worst possible moment. A compile-time check finds it the instant you write it, in the editor, before anything has run at all. The same mistake, caught at the cheapest possible point instead of one of the expensive ones.&lt;/p&gt;
&lt;p&gt;It also changes what the API &lt;em&gt;documents about itself&lt;/em&gt;. A functional-options constructor can&amp;rsquo;t tell you, from its signature alone, which options you must pass. A typestate builder can, because the set of methods available to you at each step &lt;em&gt;is&lt;/em&gt; the documentation. You literally cannot reach &lt;code&gt;.build()&lt;/code&gt; without having been walked past every required field on the way.&lt;/p&gt;
&lt;p&gt;This is one of those places where Rust&amp;rsquo;s type system earns its reputation. The builder isn&amp;rsquo;t more careful than the Go version. It&amp;rsquo;s that &amp;ldquo;this field is required&amp;rdquo; stopped being a convention and became something the compiler enforces. (Another entry, if you&amp;rsquo;re keeping score from &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;the porting post&lt;/a&gt;, in the column of outcomes that survived while the Go mechanism got left behind.)&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;Required fields have to be enforced somewhere. Functional options and ordinary builders enforce them at runtime, if at all, because &lt;code&gt;.build()&lt;/code&gt; is always callable and the type system never learns which inputs were mandatory.&lt;/p&gt;
&lt;p&gt;rust-tool-base uses typestate builders generated by &lt;code&gt;bon&lt;/code&gt;. The builder&amp;rsquo;s type changes as you set fields, and &lt;code&gt;.build()&lt;/code&gt; only exists once every required field is present. Forgetting one is a compile error that names the missing call, not a runtime surprise. The required-versus-optional distinction stops being a comment and becomes part of the type.&lt;/p&gt;</description></item><item><title>Process isolation won't save you from the filesystem</title><link>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/</guid><description>&lt;img src="https://phpboyscout.uk/process-isolation-wont-save-you-from-the-filesystem/cover-process-isolation-wont-save-you-from-the-filesystem.png" alt="Featured image of post Process isolation won't save you from the filesystem" /&gt;&lt;p&gt;A test that passed every single time I ran it on its own, and failed maybe one run in five when I ran the whole suite. The failure was always the same: the self-update test downloaded a release archive, went to extract it, and found the archive corrupt. Half-written. As if something had been scribbling in the file while it read it. Something had.&lt;/p&gt;
&lt;h2 id="the-comfort-i-was-leaning-on"&gt;The comfort I was leaning on
&lt;/h2&gt;&lt;p&gt;The self-update tests are heavier than a unit test wants to be. They stand up a fake release, download the artefact, verify its checksum, extract it, swap a binary. Real files, real I/O. So they&amp;rsquo;d been built to run as separate &lt;em&gt;processes&lt;/em&gt;, not just separate threads, each one its own little world.&lt;/p&gt;
&lt;p&gt;And I&amp;rsquo;d quietly filed that under &amp;ldquo;solved&amp;rdquo;. Separate processes don&amp;rsquo;t share an address space. One can&amp;rsquo;t reach into another&amp;rsquo;s memory and corrupt a value mid-read. That whole category of data race, the kind you reach for a mutex to fix, simply can&amp;rsquo;t happen across a process boundary. So I&amp;rsquo;d stopped thinking about concurrency in these tests at all, because I&amp;rsquo;d convinced myself the isolation was total.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t total. It was isolation of &lt;em&gt;memory&lt;/em&gt;, and I&amp;rsquo;d let myself hear it as isolation of &lt;em&gt;everything&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-processes-one-path"&gt;Two processes, one path
&lt;/h2&gt;&lt;p&gt;The thing two processes very much do still share is the filesystem. And the self-update flow, sensibly, caches its download rather than re-fetching it. The default cache directory is computed from the tool&amp;rsquo;s name and the release version, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/flow.rs#L228" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/flow.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;: &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;PathBuf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;directories&lt;/span&gt;::&lt;span class="n"&gt;ProjectDirs&lt;/span&gt;::&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tool_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map_or_else&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;::&lt;span class="n"&gt;env&lt;/span&gt;::&lt;span class="n"&gt;temp_dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;to_path_buf&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;update&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Read that with two parallel test processes in mind. They&amp;rsquo;re testing the &lt;em&gt;same&lt;/em&gt; tool, against the &lt;em&gt;same&lt;/em&gt; fake release tag. So &lt;code&gt;tool_name&lt;/code&gt; matches and &lt;code&gt;version&lt;/code&gt; matches, which means &lt;code&gt;cache_dir_for&lt;/code&gt; hands both of them the &lt;em&gt;identical path&lt;/em&gt;. Two processes, isolated in every way that involves memory, both downloading and extracting into one shared directory on disk, at the same time. One writes the archive while the other is partway through reading it, and you get exactly the corrupt half-written file the test kept tripping over.&lt;/p&gt;
&lt;p&gt;Process isolation did nothing here, because the contention was never in memory. It was on a path string that came out the same for both of them.&lt;/p&gt;
&lt;h2 id="the-fix-is-to-stop-sharing-the-path"&gt;The fix is to stop sharing the path
&lt;/h2&gt;&lt;p&gt;Once it&amp;rsquo;s framed as &amp;ldquo;they share a path&amp;rdquo;, the fix writes itself: don&amp;rsquo;t share the path. Give each invocation its own cache directory. The updater builder already had the seam for it, and the doc comment now says exactly why it&amp;rsquo;s there, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-update/src/updater.rs#L396" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-update/src/updater.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// Tools call this when they want isolation per-invocation
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// (e.g. CI runners, tests with parallel processes) or to honour
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="sd"&gt;/// a user-supplied `--cache-dir` flag.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;: &lt;span class="nc"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Into&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PathBuf&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nc"&gt;Self&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cache_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;into&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each test now builds its updater with &lt;code&gt;cache_dir(its_own_tempdir)&lt;/code&gt;, so two parallel processes land on two different directories and never meet. No lock, no serialisation, no clever cross-process file mutex. Just the realisation that the shared thing was a directory, and the cure for shared mutable state is usually to stop sharing it, not to guard it.&lt;/p&gt;
&lt;h2 id="the-fix-that-turned-out-to-be-a-feature"&gt;The fix that turned out to be a feature
&lt;/h2&gt;&lt;p&gt;The part I&amp;rsquo;m quietly pleased about is that this didn&amp;rsquo;t stay a test-only hack. The override I needed to isolate the tests is exactly the override a real tool wants for its own reasons. A CI runner doing self-update wants a writable cache path it controls, not wherever &lt;code&gt;directories-rs&lt;/code&gt; decides the system cache lives. A user might reasonably want to point the whole thing somewhere specific. That&amp;rsquo;s a &lt;code&gt;--cache-dir&lt;/code&gt; flag, and &lt;code&gt;cache_dir()&lt;/code&gt; is precisely the hook you&amp;rsquo;d wire it to.&lt;/p&gt;
&lt;p&gt;So the thing I added to stop a flaky test is the same thing a downstream tool reaches for to expose &lt;code&gt;--cache-dir&lt;/code&gt;. The test forced the seam to exist, and the seam was worth having anyway. I&amp;rsquo;ll take that trade every time over a fix that only the test suite benefits from.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d treated &amp;ldquo;separate processes&amp;rdquo; as a synonym for &amp;ldquo;can&amp;rsquo;t race&amp;rdquo;, and it isn&amp;rsquo;t. Processes don&amp;rsquo;t share memory, so the memory races are gone. They absolutely still share the filesystem, the network, every named resource the OS will hand to anyone who asks for it by the same name. My two test processes computed the same cache path from the same tool and tag, and raced on the files in it, and no amount of address-space isolation was ever going to touch that.&lt;/p&gt;
&lt;p&gt;Shared mutable state on disk is still shared mutable state. The fix wasn&amp;rsquo;t a bigger hammer, it was giving each process its own directory and letting the isolation I thought I already had actually be true.&lt;/p&gt;</description></item><item><title>Registering commands without life before main</title><link>https://phpboyscout.uk/registering-commands-without-life-before-main/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/registering-commands-without-life-before-main/</guid><description>&lt;img src="https://phpboyscout.uk/registering-commands-without-life-before-main/cover-registering-commands-without-life-before-main.png" alt="Featured image of post Registering commands without life before main" /&gt;&lt;p&gt;I ended the &lt;a class="link" href="https://phpboyscout.uk/what-survives-a-port/" &gt;last post&lt;/a&gt; promising to show how a Rust command registers itself when the language flatly refuses to run any of your code before &lt;code&gt;main()&lt;/code&gt;. This is that post, and it&amp;rsquo;s a lovely example of reaching the same outcome by a completely different road.&lt;/p&gt;
&lt;p&gt;The outcome I wanted to keep is self-registration.&lt;/p&gt;
&lt;h2 id="what-self-registration-buys"&gt;What self-registration buys
&lt;/h2&gt;&lt;p&gt;A command in go-tool-base lives in its own file, and that file puts the command into the framework itself. There&amp;rsquo;s no central list of commands to keep in sync. You add a file, the command appears. You delete the file, it&amp;rsquo;s gone. Nothing else changes.&lt;/p&gt;
&lt;p&gt;That property is worth protecting. The alternative, a hand-maintained registry that every new command has to be threaded into, is exactly the sort of central file that turns into a merge-conflict magnet and quietly falls out of date. So when go-tool-base moved to Rust, self-registration was firmly in the column of things that had to survive.&lt;/p&gt;
&lt;p&gt;The way Go &lt;em&gt;did&lt;/em&gt; it was not.&lt;/p&gt;
&lt;h2 id="how-go-does-it"&gt;How Go does it
&lt;/h2&gt;&lt;p&gt;A Go package can declare an &lt;code&gt;init()&lt;/code&gt; function, and the runtime guarantees every &lt;code&gt;init()&lt;/code&gt; runs before &lt;code&gt;main()&lt;/code&gt; starts. A go-tool-base command file uses this to append itself to a package-level slice:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;DeployCommand&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time &lt;code&gt;main()&lt;/code&gt; runs, every command file&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; has already fired and the registry slice is populated. It&amp;rsquo;s a tidy trick, and it leans entirely on a Go feature: code that executes before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="rust-doesnt-have-that"&gt;Rust doesn&amp;rsquo;t have that
&lt;/h2&gt;&lt;p&gt;Rust has no &lt;code&gt;init()&lt;/code&gt;. There&amp;rsquo;s no language-blessed phase that runs your code before &lt;code&gt;main()&lt;/code&gt;. This is a deliberate decision, not an oversight. Code running before &lt;code&gt;main()&lt;/code&gt; across many files has no well-defined order, and a startup phase whose ordering you can&amp;rsquo;t see is a classic source of subtle, miserable bugs. Rust closed that door on purpose.&lt;/p&gt;
&lt;p&gt;Which leaves a real question. If nothing runs before &lt;code&gt;main()&lt;/code&gt;, how does a command file insert itself into a registry without a central list editing it in?&lt;/p&gt;
&lt;h2 id="distributed-slices"&gt;Distributed slices
&lt;/h2&gt;&lt;p&gt;The answer is a crate called &lt;code&gt;linkme&lt;/code&gt;, and the mechanism is the &lt;em&gt;linker&lt;/em&gt; rather than a runtime phase.&lt;/p&gt;
&lt;p&gt;You declare a slice the framework will collect into:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;BUILTIN_COMMANDS&lt;/span&gt;: &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;(&lt;code&gt;Box&amp;lt;dyn Command&amp;gt;&lt;/code&gt; is just &amp;ldquo;a pointer to some value that implements the &lt;code&gt;Command&lt;/code&gt; trait, whichever concrete type it turns out to be&amp;rdquo;; the &lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;primer&lt;/a&gt; covers it if that&amp;rsquo;s unfamiliar.)&lt;/p&gt;
&lt;p&gt;A command file then contributes one entry to it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nc"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;impl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Greet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[distributed_slice(BUILTIN_COMMANDS)]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;register_greet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="nb"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;dyn&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Box&lt;/span&gt;::&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Greet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes it work. The &lt;code&gt;#[distributed_slice]&lt;/code&gt; attribute doesn&amp;rsquo;t generate any code that runs at startup. It places each entry into a dedicated section of the compiled object file. When the linker builds the final binary, it gathers everything in that section and lays it out as one contiguous array. &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/command.rs#L121" target="_blank" rel="noopener"
 &gt;&lt;code&gt;BUILTIN_COMMANDS&lt;/code&gt;&lt;/a&gt; &lt;em&gt;is&lt;/em&gt; that array.&lt;/p&gt;
&lt;p&gt;So by the time the program exists as a binary on disk, the registry is already assembled. &lt;code&gt;main()&lt;/code&gt; doesn&amp;rsquo;t build it. No &lt;code&gt;init()&lt;/code&gt; builds it. The linker built it, statically, as part of producing the executable. At runtime the framework iterates a slice that was complete before the process ever started.&lt;/p&gt;
&lt;h2 id="what-you-get-from-it"&gt;What you get from it
&lt;/h2&gt;&lt;p&gt;The outcome is the one Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt; gave, and then a bit more.&lt;/p&gt;
&lt;p&gt;A command still lives in one file and still self-registers. Adding a command is still adding a file. There&amp;rsquo;s still no central list.&lt;/p&gt;
&lt;p&gt;But there&amp;rsquo;s no startup phase to reason about, because there isn&amp;rsquo;t one. There&amp;rsquo;s no global mutable slice being appended to as &lt;code&gt;init()&lt;/code&gt;s fire, because nothing is appended at runtime; the slice is immutable and finished. There&amp;rsquo;s no ordering question, because the linker isn&amp;rsquo;t running your code, it&amp;rsquo;s collecting data. And it costs nothing at runtime: assembling the registry happened at link time, so program start just reads it.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the same idea go-tool-base had, expressed by the tool Rust actually gives you. Go reaches the registry through a controlled phase before &lt;code&gt;main()&lt;/code&gt;. Rust reaches it without any phase at all, because the linker did the assembly while the binary was still being built. Two roads, one destination&amp;hellip; which, if you&amp;rsquo;ve been following along, is becoming the whole theme of the Rust side of this project.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;Self-registration, where a command file inserts itself into the framework with no central list, is a property worth keeping. go-tool-base achieves it with a package-level &lt;code&gt;init()&lt;/code&gt;, leaning on Go&amp;rsquo;s guarantee that such functions run before &lt;code&gt;main()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Rust has no equivalent and wants none, because code running before &lt;code&gt;main()&lt;/code&gt; has no clear ordering. rust-tool-base uses &lt;code&gt;linkme&lt;/code&gt; distributed slices instead: each command is placed into a dedicated linker section, and the linker assembles them into one contiguous, immutable slice as it builds the binary. The registry is complete before the program runs. Same outcome as Go&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt;, with no life before &lt;code&gt;main&lt;/code&gt; required.&lt;/p&gt;</description></item><item><title>Verifying your own downloads: how I solved it for self-updating CLI tools</title><link>https://phpboyscout.uk/verifying-your-own-downloads/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/verifying-your-own-downloads/</guid><description>&lt;img src="https://phpboyscout.uk/verifying-your-own-downloads/cover-verifying-your-own-downloads.png" alt="Featured image of post Verifying your own downloads: how I solved it for self-updating CLI tools" /&gt;&lt;p&gt;Way back in the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;introduction&lt;/a&gt; I promised I&amp;rsquo;d come back to the self-update integrity checks. Here we are. And the starting point is a slightly uncomfortable admission: for a good long while, go-tool-base&amp;rsquo;s &lt;code&gt;update&lt;/code&gt; command was the most trusting line of code in the entire tool.&lt;/p&gt;
&lt;h2 id="the-most-trusting-line-of-code-in-the-tool"&gt;The most trusting line of code in the tool
&lt;/h2&gt;&lt;p&gt;Self-update is a lovely feature. The user runs &lt;code&gt;yourtool update&lt;/code&gt;, the tool fetches the latest release, swaps itself out, and they&amp;rsquo;re current. go-tool-base has had this since early on, wired to GitHub, GitLab, Bitbucket, Gitea and a few others.&lt;/p&gt;
&lt;p&gt;But look closely at what that feature actually does. It reaches out to the internet, pulls down a file, and then &lt;em&gt;replaces the executable that&amp;rsquo;s currently running with that file&lt;/em&gt;. The next time the user invokes the tool, they&amp;rsquo;re running whatever those bytes turned out to be.&lt;/p&gt;
&lt;p&gt;The original implementation downloaded the release asset over HTTPS and extracted it. HTTPS gets you transport security: the bytes weren&amp;rsquo;t tampered with &lt;em&gt;in flight&lt;/em&gt;. It tells you nothing about whether the bytes were right when they left, or whether they&amp;rsquo;re even the bytes you meant to fetch. A truncated download, a CDN cache serving a mangled object, a release asset that got swapped after the fact&amp;hellip; HTTPS waves all of those straight through. For the one operation in the whole tool that replaces the binary, &amp;ldquo;we didn&amp;rsquo;t check&amp;rdquo; is an uncomfortable place to be sitting.&lt;/p&gt;
&lt;h2 id="goreleaser-already-does-half-the-job"&gt;GoReleaser already does half the job
&lt;/h2&gt;&lt;p&gt;The good news is that the build side was already producing exactly what I needed. GoReleaser, which builds go-tool-base&amp;rsquo;s releases, generates a &lt;code&gt;checksums.txt&lt;/code&gt; for every release: one SHA-256 per published artefact, the same format &lt;code&gt;sha256sum&lt;/code&gt; emits. It was sitting right there as a release asset and nothing was reading it.&lt;/p&gt;
&lt;p&gt;So Phase 1 of the integrity work is exactly that: read it.&lt;/p&gt;
&lt;p&gt;When &lt;code&gt;update&lt;/code&gt; downloads the platform binary, it now also fetches &lt;code&gt;checksums.txt&lt;/code&gt; from the same release, looks up the entry for the asset it just pulled, and compares the SHA-256 of the downloaded bytes against the expected hash before anything gets extracted or installed. Mismatch, and the update aborts before it has so much as touched the installed binary. The hash comparison &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L267" target="_blank" rel="noopener"
 &gt;runs in constant time&lt;/a&gt;, which is more defence-in-depth than strictly necessary here, but it costs nothing and means every hash comparison in the codebase is the same and reassuringly audit-boring.&lt;/p&gt;
&lt;h2 id="fail-open-or-fail-closed"&gt;Fail open, or fail closed?
&lt;/h2&gt;&lt;p&gt;The interesting design question wasn&amp;rsquo;t the hashing. It was: what do you do when there &lt;em&gt;is no&lt;/em&gt; &lt;code&gt;checksums.txt&lt;/code&gt;?&lt;/p&gt;
&lt;p&gt;Plenty of older releases predate this feature. A release might have been cut by hand without GoReleaser. If go-tool-base flatly refused to update whenever a manifest was missing, the very act of shipping this feature would brick the update path for every existing tool the moment they upgraded into it. That&amp;rsquo;s a cure worse than the disease.&lt;/p&gt;
&lt;p&gt;So the default is fail-open: no manifest, log a clear warning, proceed. It matches how the existing offline-update path already behaved with its optional &lt;code&gt;.sha256&lt;/code&gt; sidecar, and it keeps upgrades working.&lt;/p&gt;
&lt;p&gt;Fail-open as a &lt;em&gt;default&lt;/em&gt; is not the same as fail-open being &lt;em&gt;right for everyone&lt;/em&gt;, though. A security-sensitive tool should be able to say &amp;ldquo;no manifest, no update, full stop&amp;rdquo;. Two ways to get there:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool authors&lt;/strong&gt; flip a compile-time switch (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/checksum.go#L42" target="_blank" rel="noopener"
 &gt;&lt;code&gt;setup.DefaultRequireChecksum = true&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;main()&lt;/code&gt;) and their binary ships fail-closed from day one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;End users&lt;/strong&gt; override either way through config (&lt;code&gt;update.require_checksum&lt;/code&gt;) or an environment variable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;go-tool-base itself ships with the strict setting turned on, because a tool whose entire job is being a careful framework should hold itself to the stricter bar.&lt;/p&gt;
&lt;h2 id="the-caveat"&gt;The caveat
&lt;/h2&gt;&lt;p&gt;Security features oversell themselves constantly, so here is the limit, stated plainly.&lt;/p&gt;
&lt;p&gt;A checksum hosted &lt;em&gt;next to&lt;/em&gt; the binary it describes protects you from accidents. Corruption, truncation, a CDN serving stale junk, a release asset that got partially clobbered. It does not protect you from a determined attacker who&amp;rsquo;s compromised the release platform itself. If someone can replace the binary, they can replace &lt;code&gt;checksums.txt&lt;/code&gt; in the same breath, and your tool will cheerfully verify a malicious download against a malicious manifest and pronounce it good.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not a flaw in the implementation. It&amp;rsquo;s the inherent ceiling of &lt;em&gt;same-origin&lt;/em&gt; integrity: the manifest and the artefact share a trust root, so they fall together. Closing that gap needs a signature whose trust root is somewhere the release platform can&amp;rsquo;t reach, a key the attacker doesn&amp;rsquo;t have. That&amp;rsquo;s the next phase of this work, and it&amp;rsquo;s a bigger piece: &lt;a class="link" href="https://phpboyscout.uk/a-signing-key-needs-somewhere-to-live/" &gt;GPG-signing the manifest&lt;/a&gt;, with the public half both embedded in the binary and published independently so a single platform compromise isn&amp;rsquo;t enough.&lt;/p&gt;
&lt;p&gt;Phase 1 is the floor, not the ceiling. But it&amp;rsquo;s a floor worth having, because the overwhelming majority of real-world &amp;ldquo;the download was wrong&amp;rdquo; incidents are accidents, not attacks, and accidents are exactly what a same-origin checksum catches.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;update&lt;/code&gt; command is the most trusting code in a self-updating tool: it fetches bytes from the internet and then becomes them. go-tool-base now verifies the SHA-256 of every self-update download against the release&amp;rsquo;s own &lt;code&gt;checksums.txt&lt;/code&gt; before installing. It fails open by default so shipping the feature doesn&amp;rsquo;t strand anyone on an un-updatable version, fails closed for tool authors who ask (go-tool-base itself does), and stays honest that a same-origin checksum stops accidents, not a platform compromise.&lt;/p&gt;
&lt;p&gt;Verifying your own downloads is a low bar. The point is that the previous height of that bar was zero.&lt;/p&gt;</description></item><item><title>Two API decisions that quietly contradict each other</title><link>https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/</guid><description>&lt;img src="https://phpboyscout.uk/two-api-decisions-that-quietly-contradict/cover-two-api-decisions-that-quietly-contradict.png" alt="Featured image of post Two API decisions that quietly contradict each other" /&gt;&lt;p&gt;Two design decisions on one enum, each sensible on its own, that would have quietly fought each other if I&amp;rsquo;d let them. I didn&amp;rsquo;t, but only because the second one is easy to get wrong and the compiler wouldn&amp;rsquo;t have said a word either way.&lt;/p&gt;
&lt;h2 id="decision-one-promise-the-list-can-grow"&gt;Decision one: promise the list can grow
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/just-enough-rust-to-follow-along/" &gt;&lt;code&gt;#[non_exhaustive]&lt;/code&gt;&lt;/a&gt; on the &lt;code&gt;Feature&lt;/code&gt; enum. It tells downstream code it can&amp;rsquo;t match the enum exhaustively, so it has to keep a wildcard arm, which in turn means adding a variant later is a non-breaking, minor-version change. Nobody&amp;rsquo;s &lt;code&gt;match&lt;/code&gt; stops compiling just because the enum grew. The doc comment says exactly that: it &amp;ldquo;keeps variant additions a minor-version change for downstream matchers.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="decision-two-hand-out-the-whole-list"&gt;Decision two: hand out the whole list
&lt;/h2&gt;&lt;p&gt;A convenience &lt;code&gt;all()&lt;/code&gt; returning every variant, because iterating over the lot is something you genuinely want to do. The tempting signature is a fixed-size array, &lt;code&gt;[Feature; 11]&lt;/code&gt;: you know precisely how many there are, so why not put it in the type?&lt;/p&gt;
&lt;h2 id="why-those-two-cant-both-be-true"&gt;Why those two can&amp;rsquo;t both be true
&lt;/h2&gt;&lt;p&gt;The catch is a quirk of Rust that often trips up people arriving from other languages: the length of a fixed-size array is part of its &lt;em&gt;type&lt;/em&gt;. &lt;code&gt;[Feature; 11]&lt;/code&gt;, an array of exactly eleven features, and &lt;code&gt;[Feature; 12]&lt;/code&gt;, exactly twelve, are not one type holding a different number of items the way they might be elsewhere. They are two genuinely different, incompatible types, about as interchangeable as &lt;code&gt;i32&lt;/code&gt; and &lt;code&gt;i64&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the moment you add a twelfth variant, a fixed-size &lt;code&gt;all()&lt;/code&gt; forces an unhappy choice, and both options are bad. Bump the array to &lt;code&gt;[Feature; 12]&lt;/code&gt; and you break every caller who wrote the old length down. Leave it at &lt;code&gt;11&lt;/code&gt; and the new variant is silently dropped, leaving you a function called &lt;code&gt;all&lt;/code&gt; that doesn&amp;rsquo;t return all of them. Either way the &lt;code&gt;#[non_exhaustive]&lt;/code&gt; promise (adding a variant breaks nobody) is quietly cancelled by a return type that welded today&amp;rsquo;s count into the public API.&lt;/p&gt;
&lt;h2 id="so-all-returns-a-slice"&gt;So &lt;code&gt;all()&lt;/code&gt; returns a slice
&lt;/h2&gt;&lt;p&gt;Which is exactly what it does, and the doc comment spells out why, in &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/features.rs#L59" target="_blank" rel="noopener"
 &gt;&lt;code&gt;crates/rtb-app/src/features.rs&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#[non_exhaustive]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Docs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Mcp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Doctor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Telemetry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Changelog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Credentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt; &lt;span class="kp"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;&amp;#39;static&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Init&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;Self&lt;/span&gt;::&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/* ...the rest... */&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A slice length is a value, not part of the type. Add a variant, the slice gets one longer, and not a single downstream signature changes. The promise holds.&lt;/p&gt;
&lt;h2 id="the-thing-to-watch-for"&gt;The thing to watch for
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;#[non_exhaustive]&lt;/code&gt; is a promise about the future. A fixed-size array is a fact about the present. You can&amp;rsquo;t keep both at once, and nothing will warn you that you&amp;rsquo;ve contradicted yourself, because each decision is individually fine. The trap is always the second API surface that quietly re-bakes the flexibility the first one promised. When you mark a type &amp;ldquo;free to grow,&amp;rdquo; go and check that nothing in its public interface has secretly written down how big it is today.&lt;/p&gt;</description></item><item><title>What survives a port, and what doesn't</title><link>https://phpboyscout.uk/what-survives-a-port/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/what-survives-a-port/</guid><description>&lt;img src="https://phpboyscout.uk/what-survives-a-port/cover-what-survives-a-port.png" alt="Featured image of post What survives a port, and what doesn't" /&gt;&lt;p&gt;Rebuilding go-tool-base in Rust turned out to be the most honest design review I&amp;rsquo;ve ever sat through, and I didn&amp;rsquo;t have to do anything except keep going. Porting a framework into a language with completely different idioms forces a separation you can&amp;rsquo;t fake: the parts that survive the move are &lt;em&gt;design&lt;/em&gt;, and the parts that don&amp;rsquo;t are just &lt;em&gt;habit&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="two-columns"&gt;Two columns
&lt;/h2&gt;&lt;p&gt;When you port a system between languages that don&amp;rsquo;t share idioms, every piece of it sorts itself into one of two columns, without you having to make the call.&lt;/p&gt;
&lt;p&gt;In the first column is the &lt;em&gt;outcome&lt;/em&gt; a piece of the design produces: every command receives the framework&amp;rsquo;s services, configuration is layered with a fixed precedence, commands register themselves, errors carry guidance to the user. In the second column is the &lt;em&gt;mechanism&lt;/em&gt; that produced that outcome in the original language.&lt;/p&gt;
&lt;p&gt;Things in the first column survive the port. You rebuild them, differently, because the tool genuinely needs them. Things in the second column do not survive. You find their replacement, and the Go version turns out to have been one valid implementation of an idea, not the idea itself. Doing this for go-tool-base, mechanism by mechanism, was more honest about my own design than any amount of sitting and staring at it would have been.&lt;/p&gt;
&lt;h2 id="the-container"&gt;The container
&lt;/h2&gt;&lt;p&gt;go-tool-base hands every command a &lt;code&gt;Props&lt;/code&gt; struct. It carries the logger, the config, the assets, the filesystem handle. Some of it is reached through loosely-typed accessors. It works well, and I &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;wrote a whole post about it&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;em&gt;outcome&lt;/em&gt; is column one: a command should receive one object, and that object should carry the framework&amp;rsquo;s services so the command doesn&amp;rsquo;t go assembling them itself. That survived. RTB hands every command an &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-app/src/app.rs#L32" target="_blank" rel="noopener"
 &gt;&lt;code&gt;App&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The loosely-typed accessors were column two. In Rust an &lt;code&gt;App&lt;/code&gt; is a plain struct with concrete fields, each one an &lt;code&gt;Arc&amp;lt;T&amp;gt;&lt;/code&gt; so a clone is a few atomic increments rather than a deep copy. Nothing is keyed by string. Nothing is fetched by name and asserted to a type. The thing the container &lt;em&gt;is for&lt;/em&gt; survived; the way Go expressed it did not.&lt;/p&gt;
&lt;h2 id="registration"&gt;Registration
&lt;/h2&gt;&lt;p&gt;A go-tool-base command self-registers using a package-level &lt;code&gt;init()&lt;/code&gt; function, which Go runs before &lt;code&gt;main()&lt;/code&gt; and which appends the command to a global slice.&lt;/p&gt;
&lt;p&gt;The outcome, column one, is that a command lives in its own file and inserts itself into the framework with no central list to edit. That&amp;rsquo;s genuinely worth keeping.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;init()&lt;/code&gt; mechanism is column two, and Rust doesn&amp;rsquo;t even offer it: Rust deliberately has no code that runs before &lt;code&gt;main()&lt;/code&gt;. The replacement is link-time registration through distributed slices, which gets its &lt;a class="link" href="https://phpboyscout.uk/registering-commands-without-life-before-main/" &gt;own post next&lt;/a&gt;. Same outcome, no global mutable state, assembled by the linker rather than by a startup phase.&lt;/p&gt;
&lt;h2 id="configuration"&gt;Configuration
&lt;/h2&gt;&lt;p&gt;go-tool-base layers configuration with a precedence: flags over environment over file over defaults. Some of it is read back through key lookups.&lt;/p&gt;
&lt;p&gt;The layering and the precedence are column one. They survived exactly. RTB layers config with the same ordering.&lt;/p&gt;
&lt;p&gt;The key lookups were column two. In Rust the merged configuration is deserialised into &lt;em&gt;your own&lt;/em&gt; &lt;code&gt;serde&lt;/code&gt; struct, so a config value is a typed field you access like any other field, and a typo is a compile error instead of a missing key at runtime. The precedence survived; reading values back out of a string-keyed bag did not.&lt;/p&gt;
&lt;h2 id="the-error-path"&gt;The error path
&lt;/h2&gt;&lt;p&gt;go-tool-base routes every error through one handler so presentation is consistent, which I &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;also wrote up&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One consistent exit for errors is column one. It survived. What didn&amp;rsquo;t survive was the &lt;em&gt;handler&lt;/em&gt;: RTB has no error-handler object at all, because Rust&amp;rsquo;s own return-from-&lt;code&gt;main&lt;/code&gt; convention plus a report hook does the job the handler was built to do. That one has &lt;a class="link" href="https://phpboyscout.uk/errors-without-an-error-handler/" &gt;its own post too&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="what-the-exercise-was-actually-worth"&gt;What the exercise was actually worth
&lt;/h2&gt;&lt;p&gt;Every mechanism told the same story. The container, the registration, the config access, the error path, the cancellation signal that go-tool-base carries on a &lt;code&gt;context.Context&lt;/code&gt; and RTB carries on a &lt;code&gt;CancellationToken&lt;/code&gt;. In every case the &lt;em&gt;thing it achieved&lt;/em&gt; walked across to Rust untouched, and the &lt;em&gt;Go code that achieved it&lt;/em&gt; was left behind.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the useful result. Before this port I couldn&amp;rsquo;t have told you, for any given pattern in go-tool-base, whether it was load-bearing design or just the idiomatic Go way to write it that day. Now I can, because each one was forced to prove itself by being rebuilt from nothing in a language that flatly wouldn&amp;rsquo;t accept the original. Whatever survived was real. Whatever I had to replace was always replaceable, which means it was never really the point.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;Porting a framework into a language with different idioms separates design from habit for free. The outcome a pattern produces is design, and it survives the move. The mechanism that produced it is idiom, and it gets left behind for the new language&amp;rsquo;s equivalent.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;Props&lt;/code&gt; bag, its &lt;code&gt;init()&lt;/code&gt; registration, its key-based config access and its error handler were all idiom. The single context object, self-registration, layered precedence and a consistent error exit were all design, and all four came through to RTB intact. The next three posts take the most interesting replacements one at a time, starting with how a Rust command registers itself when the language won&amp;rsquo;t run anything before &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;</description></item><item><title>The blank import that keeps a dependency out of your binary</title><link>https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/</guid><description>&lt;img src="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/cover-the-blank-import-that-keeps-a-dependency-out-of-your-binary.png" alt="Featured image of post The blank import that keeps a dependency out of your binary" /&gt;&lt;p&gt;go-tool-base can stash your credentials in the OS keychain, which most people building on it are perfectly happy about. But some of them ship into regulated and air-gapped environments where the binary isn&amp;rsquo;t &lt;em&gt;permitted&lt;/em&gt; to contain keychain or session-bus code at all&amp;hellip; not dormant, not unused, simply not there.&lt;/p&gt;
&lt;p&gt;So I had a feature most users want and a minority must be able to provably not have. The way I ended up solving it is one of my favourite little bits of honest Go.&lt;/p&gt;
&lt;h2 id="a-feature-some-users-have-to-be-able-to-not-have"&gt;A feature some users have to be able to &lt;em&gt;not have&lt;/em&gt;
&lt;/h2&gt;&lt;p&gt;go-tool-base needs somewhere to keep secrets: AI provider keys, VCS tokens, the occasional app password. The best home for those on a developer&amp;rsquo;s machine is the operating system&amp;rsquo;s own keychain. macOS Keychain, GNOME Keyring or KWallet on Linux via the Secret Service, Windows Credential Manager. So I wanted go-tool-base to support all three. (This is the keychain mode I mentioned back in the &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;credentials post&lt;/a&gt;, finally getting the explanation I promised it.)&lt;/p&gt;
&lt;p&gt;The Go library for that is &lt;a class="link" href="https://github.com/zalando/go-keyring" target="_blank" rel="noopener"
 &gt;&lt;code&gt;go-keyring&lt;/code&gt;&lt;/a&gt;, and it&amp;rsquo;s good. The catch is what it drags in behind it. On Linux it talks to the Secret Service over D-Bus, which means &lt;code&gt;godbus&lt;/code&gt;. On Windows it pulls &lt;code&gt;wincred&lt;/code&gt;. Perfectly reasonable dependencies for a desktop tool.&lt;/p&gt;
&lt;p&gt;Now here&amp;rsquo;s the constraint that made this interesting. Some of the people building tools on go-tool-base don&amp;rsquo;t ship to developer laptops. They ship into regulated sectors and air-gapped deployments where a security review will scan the binary, enumerate every dependency, and ask pointed questions about anything that does inter-process communication. For those builds, &amp;ldquo;the keychain code is there but we never call it&amp;rdquo; is not an acceptable answer. The reviewer&amp;rsquo;s position, and it&amp;rsquo;s a fair one, is that code which isn&amp;rsquo;t in the binary cannot be a finding.&lt;/p&gt;
&lt;p&gt;So I had a feature that most users want, and a minority of users must be able to provably &lt;em&gt;not have&lt;/em&gt;. Same framework, same release.&lt;/p&gt;
&lt;h2 id="why-i-didnt-reach-for-a-build-tag"&gt;Why I didn&amp;rsquo;t reach for a build tag
&lt;/h2&gt;&lt;p&gt;The obvious Go answer is a build tag. Compile with &lt;code&gt;-tags keychain&lt;/code&gt; to get it, leave the tag off to not. I started down that road. I even spent a while on an inverted version, a &lt;code&gt;nokeychain&lt;/code&gt; tag, on the theory that the regulated build should be the one that has to ask, so a forgotten flag fails safe.&lt;/p&gt;
&lt;p&gt;It works. It also isn&amp;rsquo;t very nice. Build tags are invisible at the call site. Nothing in the source tells you that a file only exists in some builds. The two worlds drift, because the tagged-out path isn&amp;rsquo;t compiled in your normal editor session and quietly rots. And the ergonomics for a &lt;em&gt;downstream consumer&lt;/em&gt; are poor: every tool built on go-tool-base would have to know the right magic incantation and thread it through their own release pipeline correctly, forever.&lt;/p&gt;
&lt;p&gt;I tried a second approach too: pull the keychain backend out into a completely separate Go module. That genuinely solves the dependency question (a module you don&amp;rsquo;t require can&amp;rsquo;t contribute to your &lt;code&gt;go.sum&lt;/code&gt;). But a separate module for one backend is clunky. Separate versioning, separate release, separate repo, all for a single file&amp;rsquo;s worth of behaviour. It felt like using a shipping container to post a letter.&lt;/p&gt;
&lt;h2 id="the-shape-that-actually-fits-a-registry-and-an-init"&gt;The shape that actually fits: a registry and an &lt;code&gt;init()&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;The version I&amp;rsquo;m happy with leans on two boring, well-worn Go mechanisms and lets them do something quietly clever together.&lt;/p&gt;
&lt;p&gt;First, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/backend.go#L27" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/credentials&lt;/code&gt;&lt;/a&gt; defines a &lt;code&gt;Backend&lt;/code&gt; interface and a registry. By default the registry holds a stub backend that politely returns &amp;ldquo;unsupported&amp;rdquo; for everything. The framework only ever talks to &lt;em&gt;the registered backend&lt;/em&gt;, whatever that happens to be.&lt;/p&gt;
&lt;p&gt;Second, the keychain implementation lives in its own package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/keychain/keychain.go#L97" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/credentials/keychain&lt;/code&gt;&lt;/a&gt;, still inside the same module, no separate release to manage. That package has an &lt;code&gt;init()&lt;/code&gt; that registers its &lt;code&gt;go-keyring&lt;/code&gt;-backed backend:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;//nolint:gochecknoinits // registration via import is the whole point&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RegisterBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And &lt;code&gt;go-keyring&lt;/code&gt;, &lt;code&gt;godbus&lt;/code&gt;, &lt;code&gt;wincred&lt;/code&gt;, the whole IPC dependency chain, are only imported by &lt;em&gt;that&lt;/em&gt; package.&lt;/p&gt;
&lt;p&gt;Now the trick. To switch keychain support on, you import the package. You don&amp;rsquo;t have to &lt;em&gt;use&lt;/em&gt; anything from it. A blank import is enough, because a blank import still runs the package&amp;rsquo;s &lt;code&gt;init()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// cmd/gtb/keychain.go - the entire file.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;package&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;gitlab.com/phpboyscout/go-tool-base/pkg/credentials/keychain&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That single line is the on/off switch for the shipped &lt;code&gt;gtb&lt;/code&gt; binary. The blank import means &lt;code&gt;init()&lt;/code&gt; runs, the keychain backend registers itself, and credential operations start routing through the OS keychain. No flag, no tag, no config.&lt;/p&gt;
&lt;h2 id="the-part-that-makes-it-provable"&gt;The part that makes it provable
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s why this beats the build tag, and it comes down to one guarantee in the Go toolchain: &lt;strong&gt;the linker only includes packages that are actually imported.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If &lt;code&gt;cmd/gtb/keychain.go&lt;/code&gt; exists, the &lt;code&gt;keychain&lt;/code&gt; package is in the import graph, so &lt;code&gt;go-keyring&lt;/code&gt;, &lt;code&gt;godbus&lt;/code&gt; and &lt;code&gt;wincred&lt;/code&gt; are linked in. Delete that one file and rebuild, and the &lt;code&gt;keychain&lt;/code&gt; package is no longer reachable from &lt;code&gt;main&lt;/code&gt;. The linker performs dead-code elimination, and the entire &lt;code&gt;go-keyring&lt;/code&gt; chain is &lt;em&gt;gone&lt;/em&gt;. Not dormant. Not present-but-unused. Absent from the binary.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the bit a regulated build needs. It isn&amp;rsquo;t a promise that the code won&amp;rsquo;t run. It&amp;rsquo;s a structural fact that the code isn&amp;rsquo;t there, and you can hand a security reviewer an SBOM that proves it. &lt;code&gt;go-keyring&lt;/code&gt; won&amp;rsquo;t appear, because it genuinely isn&amp;rsquo;t linked.&lt;/p&gt;
&lt;p&gt;For a downstream tool built on go-tool-base the story is the same, and just as cheap. Want keychain support? Add the one-line blank import to your own &lt;code&gt;cmd&lt;/code&gt; package. Must ship keychain-free? Don&amp;rsquo;t. Your binary&amp;rsquo;s dependency graph follows your import graph, exactly as Go always promised it would. The default (no import) is the locked-down one, which is the right way round for a safety property.&lt;/p&gt;
&lt;h2 id="why-i-like-this-more-than-i-expected-to"&gt;Why I like this more than I expected to
&lt;/h2&gt;&lt;p&gt;Build tags hide a decision in the compiler invocation. This pattern puts the decision in the source, as an import, where it&amp;rsquo;s greppable, obvious in code review, and impossible to get subtly wrong. There&amp;rsquo;s a real file called &lt;code&gt;keychain.go&lt;/code&gt; whose entire content is one import, and it reads as exactly what it is: a switch.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s also just &lt;em&gt;honest&lt;/em&gt; Go. No reflection, no plugin loader, no clever runtime. A registry, an &lt;code&gt;init()&lt;/code&gt;, and the linker doing the one job it&amp;rsquo;s always done. The cleverness, such as it is, is in the arrangement, not in any individual piece.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;go-tool-base needed OS keychain support for the many, and a way to provably exclude it for the few. Build tags could express the toggle but hid it in the build invocation and rotted in the dark. A separate module solved the dependency question but was far too much machinery for one backend.&lt;/p&gt;
&lt;p&gt;Putting the keychain backend in its own package, activated by a blank &lt;code&gt;import _&lt;/code&gt; that fires its &lt;code&gt;init()&lt;/code&gt;, gets you both: a one-line, in-source, code-reviewable switch, and, because the linker only links what&amp;rsquo;s imported, a build with the import omitted that contains &lt;em&gt;none&lt;/em&gt; of the keychain dependency chain. Provable absence, not promised disuse.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re carrying an optional dependency that some of your users need gone rather than merely idle, this is the pattern. Let the import graph be the feature flag.&lt;/p&gt;</description></item><item><title>Just enough Rust to follow along</title><link>https://phpboyscout.uk/just-enough-rust-to-follow-along/</link><pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/just-enough-rust-to-follow-along/</guid><description>&lt;img src="https://phpboyscout.uk/just-enough-rust-to-follow-along/cover-just-enough-rust-to-follow-along.png" alt="Featured image of post Just enough Rust to follow along" /&gt;&lt;p&gt;I&amp;rsquo;m about to write a run of posts about building rust-tool-base, and they lean on a handful of Rust ideas that I&amp;rsquo;d otherwise have to keep stopping to explain. So here they are, up front, in one place. You don&amp;rsquo;t need to write Rust to follow the series. You need a feel for maybe six concepts, and this is a quick, friendly tour of them. If you already write Rust, skip it with my blessing.&lt;/p&gt;
&lt;h2 id="ownership-and-borrowing"&gt;Ownership and borrowing
&lt;/h2&gt;&lt;p&gt;This is the one everybody mentions, and the one the whole language is built around. Every value in Rust has exactly one &lt;em&gt;owner&lt;/em&gt;, and when the owner goes away, the value is cleaned up. No garbage collector deciding when, no manual &lt;code&gt;free&lt;/code&gt;. If you want to let another piece of code use a value without handing over ownership, you &lt;em&gt;borrow&lt;/em&gt; it: &lt;code&gt;&amp;amp;thing&lt;/code&gt; lends it out for reading, &lt;code&gt;&amp;amp;mut thing&lt;/code&gt; for writing, and the compiler enforces that you can&amp;rsquo;t, say, change something while someone else is reading it.&lt;/p&gt;
&lt;p&gt;The payoff, and the reason people put up with the up-front fuss, is that an entire family of bug (use-after-free, data races, dangling pointers) becomes a &lt;em&gt;compile&lt;/em&gt; error rather than a 3am one. When a post says something &amp;ldquo;moves&amp;rdquo; or is &amp;ldquo;borrowed&amp;rdquo;, that&amp;rsquo;s all this is.&lt;/p&gt;
&lt;h2 id="traits-are-rusts-interfaces"&gt;Traits are Rust&amp;rsquo;s interfaces
&lt;/h2&gt;&lt;p&gt;A &lt;em&gt;trait&lt;/em&gt; is a named set of methods a type can promise to provide, exactly like an interface in Go or Java. &lt;code&gt;impl Command for Greet { ... }&lt;/code&gt; reads as &amp;ldquo;the &lt;code&gt;Greet&lt;/code&gt; type fulfils the &lt;code&gt;Command&lt;/code&gt; contract.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Two bits of syntax show up a lot. &lt;code&gt;dyn Command&lt;/code&gt; means &amp;ldquo;some value whose concrete type I don&amp;rsquo;t know, but which implements &lt;code&gt;Command&lt;/code&gt;&amp;rdquo;, decided at runtime. And because the compiler needs a known size, you usually see it wrapped: &lt;code&gt;Box&amp;lt;dyn Command&amp;gt;&lt;/code&gt; is &amp;ldquo;a pointer to some &lt;code&gt;Command&lt;/code&gt;, whatever it turns out to be.&amp;rdquo; Whenever the series talks about a registry of &lt;code&gt;Box&amp;lt;dyn Something&amp;gt;&lt;/code&gt;, it just means a list of different types that all satisfy the same trait.&lt;/p&gt;
&lt;h2 id="enums-match-and-non_exhaustive"&gt;Enums, &lt;code&gt;match&lt;/code&gt;, and &lt;code&gt;#[non_exhaustive]&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;A Rust &lt;code&gt;enum&lt;/code&gt; is more than a list of named numbers; it&amp;rsquo;s a proper &amp;ldquo;one of these&amp;rdquo; type, and each variant can carry its own data. You handle one with &lt;code&gt;match&lt;/code&gt;, which is like a &lt;code&gt;switch&lt;/code&gt; that the compiler &lt;em&gt;forces&lt;/em&gt; you to make complete: miss a case and it won&amp;rsquo;t build.&lt;/p&gt;
&lt;p&gt;That completeness is usually a gift, but it&amp;rsquo;s awkward for a library, because adding a new variant would break everyone&amp;rsquo;s &lt;code&gt;match&lt;/code&gt;. The fix is the attribute &lt;code&gt;#[non_exhaustive]&lt;/code&gt;: it tells code outside the library &amp;ldquo;you must keep a catch-all &lt;code&gt;_ =&amp;gt;&lt;/code&gt; arm, because I reserve the right to add variants later.&amp;rdquo; With that in place, growing the enum is a non-breaking change. (One whole post turns on a subtle way to &lt;em&gt;accidentally&lt;/em&gt; cancel that promise.)&lt;/p&gt;
&lt;h2 id="the-type-system-carries-facts-not-just-shapes"&gt;The type system carries facts, not just shapes
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s an idea that surprises people coming from other languages: a Rust type often encodes &lt;em&gt;more&lt;/em&gt; than &amp;ldquo;this is a number&amp;rdquo; or &amp;ldquo;this is a list.&amp;rdquo; The size of a fixed array is part of its type, so &lt;code&gt;[Feature; 11]&lt;/code&gt; and &lt;code&gt;[Feature; 12]&lt;/code&gt; are genuinely different, incompatible types, not one type holding a different count.&lt;/p&gt;
&lt;p&gt;Pushed further, you can make the type track &lt;em&gt;state&lt;/em&gt;. A &amp;ldquo;typestate&amp;rdquo; builder changes type as you call it, so &lt;code&gt;.build()&lt;/code&gt; literally doesn&amp;rsquo;t exist as a method until every required field has been set, and forgetting one is a compile error rather than a runtime surprise. When a post says the compiler &amp;ldquo;won&amp;rsquo;t let you&amp;rdquo; do something, this is usually how: the mistake was made unrepresentable in the types.&lt;/p&gt;
&lt;h2 id="result-and-the--operator"&gt;&lt;code&gt;Result&lt;/code&gt; and the &lt;code&gt;?&lt;/code&gt; operator
&lt;/h2&gt;&lt;p&gt;Rust has no exceptions. A function that can fail returns a &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt;: either &lt;code&gt;Ok(value)&lt;/code&gt; or &lt;code&gt;Err(problem)&lt;/code&gt;, and you can&amp;rsquo;t use the value without acknowledging the error case. Writing that check by hand everywhere would be miserable, so there&amp;rsquo;s a shorthand: the &lt;code&gt;?&lt;/code&gt; operator. &lt;code&gt;let x = thing()?;&lt;/code&gt; means &amp;ldquo;if this returned an error, return it up to my caller right now; otherwise give me the value.&amp;rdquo; Errors travel up the call stack as ordinary return values until something handles them, or until they fall out of &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="crates-the-workspace-and-features"&gt;Crates, the workspace, and features
&lt;/h2&gt;&lt;p&gt;A &lt;em&gt;crate&lt;/em&gt; is Rust&amp;rsquo;s unit of compilation, roughly &amp;ldquo;a library or binary.&amp;rdquo; A &lt;em&gt;workspace&lt;/em&gt; is a bundle of crates built together, which is how rust-tool-base is laid out: &lt;code&gt;rtb-app&lt;/code&gt;, &lt;code&gt;rtb-cli&lt;/code&gt;, &lt;code&gt;rtb-config&lt;/code&gt; and so on, each its own crate. And &lt;em&gt;Cargo features&lt;/em&gt; are compile-time switches declared in &lt;code&gt;Cargo.toml&lt;/code&gt;: turn a feature off and the code it guards, and any dependency it pulled in, is never compiled into your binary at all. Not disabled at runtime; simply absent. That distinction does real work in one of the posts.&lt;/p&gt;
&lt;h2 id="thats-the-toolkit"&gt;That&amp;rsquo;s the toolkit
&lt;/h2&gt;&lt;p&gt;Ownership and borrowing, traits and &lt;code&gt;dyn&lt;/code&gt;, enums and &lt;code&gt;match&lt;/code&gt; and &lt;code&gt;#[non_exhaustive]&lt;/code&gt;, types that carry facts, &lt;code&gt;Result&lt;/code&gt; and &lt;code&gt;?&lt;/code&gt;, and crates with features. Six ideas, and they&amp;rsquo;re enough to read everything else in this series without tripping over the language itself. Where a post needs a seventh thing, it&amp;rsquo;ll explain it in passing. Now, on with the actual building.&lt;/p&gt;</description></item><item><title>Where should a CLI keep your API keys?</title><link>https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/</guid><description>&lt;img src="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/cover-where-should-a-cli-keep-your-api-keys.png" alt="Featured image of post Where should a CLI keep your API keys?" /&gt;&lt;p&gt;Your CLI tool needs the user&amp;rsquo;s API key. It has to come from somewhere, and it has to survive between runs, so the obvious move is to ask once and write it into the config file. One tidy &lt;code&gt;api_key:&lt;/code&gt; line. Job done.&lt;/p&gt;
&lt;p&gt;It works beautifully on the first afternoon. And then, months later, it&amp;rsquo;s quietly become a liability nobody actually decided to create.&lt;/p&gt;
&lt;h2 id="the-config-file-that-quietly-becomes-a-liability"&gt;The config file that quietly becomes a liability
&lt;/h2&gt;&lt;p&gt;Your CLI tool needs the user&amp;rsquo;s API key. It has to come from somewhere, and it has to survive between invocations, so the obvious move is to ask once and write it into the tool&amp;rsquo;s config file. &lt;code&gt;~/.config/yourtool/config.yaml&lt;/code&gt;, a nice &lt;code&gt;api_key:&lt;/code&gt; line, done.&lt;/p&gt;
&lt;p&gt;It works on the first afternoon. It keeps working. And then, slowly, it becomes a problem nobody decided to create.&lt;/p&gt;
&lt;p&gt;The config file gets committed to a dotfiles repo. It gets caught in a &lt;code&gt;tar&lt;/code&gt; of someone&amp;rsquo;s home directory that lands in a backup bucket. It scrolls past in a screen share. It sits, world-readable, on a shared build box. None of these are exotic. They&amp;rsquo;re just a Tuesday. The plaintext key was fine right up until the file went somewhere the key shouldn&amp;rsquo;t, and config files go places.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want go-tool-base handing every tool built on it that same slow-motion liability by default. So credential handling got rebuilt around a simple idea: the config file should usually hold a &lt;em&gt;reference&lt;/em&gt; to the secret, not the secret itself.&lt;/p&gt;
&lt;h2 id="three-modes-and-which-one-you-get"&gt;Three modes, and which one you get
&lt;/h2&gt;&lt;p&gt;go-tool-base supports &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/credentials/mode.go#L18" target="_blank" rel="noopener"
 &gt;three ways to store a credential&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment-variable reference, the default.&lt;/strong&gt; The config records the &lt;em&gt;name&lt;/em&gt; of an environment variable, not its value:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The secret itself lives in your shell profile, your &lt;code&gt;direnv&lt;/code&gt; setup, or your CI platform&amp;rsquo;s secret store, wherever you already keep that sort of thing. The config file now contains nothing sensitive at all. You can commit it, back it up, paste it into a bug report. The reference is inert on its own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OS keychain, opt-in.&lt;/strong&gt; The config holds a &lt;code&gt;&amp;lt;service&amp;gt;/&amp;lt;account&amp;gt;&lt;/code&gt; reference and the actual secret goes into the operating system&amp;rsquo;s keychain: macOS Keychain, GNOME Keyring or KWallet via the Secret Service, Windows Credential Manager.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;keychain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;mytool/anthropic.api&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This one is opt-in by design, because the keychain backend carries dependencies that some deployments simply aren&amp;rsquo;t allowed to ship. (That opt-in mechanism turned out to be an interesting little problem all of its own, and it gets &lt;a class="link" href="https://phpboyscout.uk/the-blank-import-that-keeps-a-dependency-out-of-your-binary/" &gt;its own post&lt;/a&gt; in a couple of days.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Literal value, legacy and grudging.&lt;/strong&gt; The old behaviour. The secret sits in the config in plaintext:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sk-ant-...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It still works, because breaking every existing tool&amp;rsquo;s config on an upgrade would be its own kind of vandalism. But it&amp;rsquo;s the last resort, it&amp;rsquo;s documented as the last resort, and the setup wizard puts a warning in front of you when you pick it.&lt;/p&gt;
&lt;h2 id="the-one-place-literal-mode-is-not-allowed"&gt;The one place literal mode is not allowed
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a single hard &amp;ldquo;no&amp;rdquo; in all of this. If go-tool-base detects it&amp;rsquo;s running in CI (&lt;code&gt;CI=true&lt;/code&gt;, which every major CI platform sets) the setup flow will &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/ai/ai.go#L177" target="_blank" rel="noopener"
 &gt;&lt;em&gt;refuse&lt;/em&gt; to write a literal credential&lt;/a&gt;, and exits non-zero.&lt;/p&gt;
&lt;p&gt;The reasoning is that a plaintext secret written during a CI run is a plaintext secret written onto an ephemeral, often shared, frequently-logged machine, by an automated process that no human is watching. That&amp;rsquo;s the exact situation where the slow-motion liability becomes a fast one. CI environments inject secrets as environment variables already; there&amp;rsquo;s no good reason for a tool to be writing one to disk there, so go-tool-base simply won&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="how-it-decides-at-runtime"&gt;How it decides at runtime
&lt;/h2&gt;&lt;p&gt;A credential can be configured more than one way at once. You might have an &lt;code&gt;env&lt;/code&gt; reference &lt;em&gt;and&lt;/em&gt; an old literal &lt;code&gt;key&lt;/code&gt; still lurking. So resolution follows a fixed precedence, highest to lowest:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;*.env&lt;/code&gt; reference. If that env var is set, use it.&lt;/li&gt;
&lt;li&gt;Otherwise the &lt;code&gt;*.keychain&lt;/code&gt; reference. If a keychain entry resolves, use it.&lt;/li&gt;
&lt;li&gt;Otherwise the literal &lt;code&gt;*.key&lt;/code&gt; / &lt;code&gt;*.value&lt;/code&gt;, the legacy path.&lt;/li&gt;
&lt;li&gt;Otherwise a well-known fallback env var (&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; and friends), so a tool still picks up the ecosystem-standard variable with no config at all.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The useful property here is that adding a more secure mode &lt;em&gt;transparently wins&lt;/em&gt;. Drop an &lt;code&gt;env&lt;/code&gt; reference next to an old literal key and the next run uses the env var. You can migrate a credential to a better home without first removing it from its worse one, which makes the migration safe to do incrementally instead of as one nervous big-bang edit.&lt;/p&gt;
&lt;h2 id="the-tool-tells-on-itself"&gt;The tool tells on itself
&lt;/h2&gt;&lt;p&gt;A precedence rule is no use if nobody knows their config still has a plaintext key three layers down. So the built-in &lt;code&gt;doctor&lt;/code&gt; command grew a check for exactly that. Run &lt;code&gt;doctor&lt;/code&gt;, and if any literal credential is sitting in your config it reports a warning, names the offending keys (the key &lt;em&gt;names&lt;/em&gt;, never the values) and points you at how to migrate.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not an error. Literal mode is still legal. But the tool will quietly keep reminding you that you left the campsite messier than you could have, until you go and tidy it. (Old Scout habits die hard, and they&amp;rsquo;ve leaked all the way into the framework.)&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;A CLI tool that writes your API key into a plaintext config file isn&amp;rsquo;t doing anything &lt;em&gt;wrong&lt;/em&gt;, exactly. It&amp;rsquo;s just handing you a liability that activates later, when the file travels somewhere the key shouldn&amp;rsquo;t. go-tool-base&amp;rsquo;s answer is three storage modes: an env-var reference by default, the OS keychain on request, and a plaintext literal only as a documented last resort that CI environments can&amp;rsquo;t use at all. Runtime resolution runs in a fixed precedence so a more secure mode always wins, which makes migrating a credential safe to do gradually. And &lt;code&gt;doctor&lt;/code&gt; keeps an eye on the config so a stray plaintext secret doesn&amp;rsquo;t get to hide forever.&lt;/p&gt;
&lt;p&gt;The secret should live in a secret store. The config file should just know its name.&lt;/p&gt;</description></item><item><title>A configurable AI endpoint is an attack surface</title><link>https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/</link><pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/</guid><description>&lt;img src="https://phpboyscout.uk/a-configurable-ai-endpoint-is-an-attack-surface/cover-a-configurable-ai-endpoint-is-an-attack-surface.png" alt="Featured image of post A configurable AI endpoint is an attack surface" /&gt;&lt;p&gt;&amp;ldquo;Let users point at their own AI endpoint&amp;rdquo; is one of those config options that looks completely harmless on the way in. People want it, for perfectly good reasons. Then you sit with it for a minute and realise you&amp;rsquo;ve handed every user a loaded gun and pointed it vaguely at their own API key.&lt;/p&gt;
&lt;h2 id="why-you-offer-it-at-all"&gt;Why you offer it at all
&lt;/h2&gt;&lt;p&gt;There are real reasons to let someone set a custom base URL. They&amp;rsquo;re running a local model and want &lt;code&gt;localhost:11434&lt;/code&gt;. They&amp;rsquo;re behind a corporate proxy that fronts the real provider. They&amp;rsquo;re on Azure&amp;rsquo;s flavour of OpenAI, which lives at a different host. They&amp;rsquo;ve a self-hosted gateway doing rate-limiting. All reasonable, all things a framework should support rather than fight.&lt;/p&gt;
&lt;h2 id="the-bit-thats-a-loaded-gun"&gt;The bit that&amp;rsquo;s a loaded gun
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s what the config option quietly decides: the base URL is &lt;em&gt;where your credential goes&lt;/em&gt;. The API key rides along in an &lt;code&gt;Authorization&lt;/code&gt; header on every request, to whatever host that URL resolves to. So the moment the endpoint is user-configurable, the destination of your secret is user-configurable too.&lt;/p&gt;
&lt;p&gt;And users do user things. They paste a URL from a gist that turned out to be a honeypot. They leave &lt;code&gt;http://&lt;/code&gt; on the front, so the key crosses the wire in plaintext. They copy &lt;code&gt;https://user:token@host/v1&lt;/code&gt; not realising the userinfo changes who they actually authenticate to. They never edit the &lt;code&gt;https://api.example.com/v1&lt;/code&gt; placeholder and wonder why the key&amp;rsquo;s been posted to a domain they don&amp;rsquo;t own. None of that is malice. It&amp;rsquo;s what happens when the destination of a secret is a free-text field.&lt;/p&gt;
&lt;h2 id="validate-before-the-first-byte-leaves"&gt;Validate before the first byte leaves
&lt;/h2&gt;&lt;p&gt;So every &lt;code&gt;chat.New&lt;/code&gt; routes through &lt;code&gt;ValidateBaseURL&lt;/code&gt; before the provider is built. The threat model is written at the top of &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/baseurl.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/chat/baseurl.go&lt;/code&gt;&lt;/a&gt;: an operator who can influence config could &amp;ldquo;redirect chat-provider traffic to an attacker-controlled HTTPS host and capture the Authorization header.&amp;rdquo; The checks run cheapest-first: a length cap, no ASCII control characters, must parse, no userinfo, &lt;code&gt;https&lt;/code&gt; only, a host must be present, and the host mustn&amp;rsquo;t be a placeholder.&lt;/p&gt;
&lt;p&gt;The userinfo rule is the sharp one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// Reject any userinfo, with or without password. Never log&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// the URL itself because it contains the credential.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ErrInvalidBaseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;		&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;base URL must not contain credentials; use the Token field instead&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The placeholder check rejects &lt;code&gt;example.com&lt;/code&gt; and friends &lt;em&gt;and any subdomain of them&lt;/em&gt;, so the unedited &lt;code&gt;https://api.example.com/v1&lt;/code&gt; from a setup wizard never reaches the wire and hits some typosquatted lookalike. And the HTTP escape hatch is test-only by construction: the &lt;code&gt;AllowInsecureBaseURL&lt;/code&gt; field that permits plain &lt;code&gt;http&lt;/code&gt; is tagged &lt;code&gt;json:&amp;quot;-&amp;quot;&lt;/code&gt;, so a config file physically cannot set it. This all came out of the 2026-04-17 security audit, finding M-3.&lt;/p&gt;
&lt;p&gt;rust-tool-base enforces the same at its own boundary: &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/config.rs#L96" target="_blank" rel="noopener"
 &gt;&lt;code&gt;validate_base_url&lt;/code&gt;&lt;/a&gt; rejects userinfo, any scheme but &lt;code&gt;https&lt;/code&gt; (bar a test-only &lt;code&gt;allow_insecure&lt;/code&gt;), and documentation placeholder hosts like &lt;code&gt;example.com&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="what-it-can-and-cant-do"&gt;What it can and can&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;It won&amp;rsquo;t stop a user who deliberately points the tool at a malicious HTTPS host they genuinely chose. If someone is set on sending their own key somewhere bad, validation can&amp;rsquo;t read their mind.&lt;/p&gt;
&lt;p&gt;What it stops is the &lt;em&gt;accidents&lt;/em&gt;: the plaintext slip, the userinfo confusion, the placeholder nobody changed. Those aren&amp;rsquo;t theoretical, they&amp;rsquo;re the ones that happen to careful people on ordinary days. Storing the key well is one job (&lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI keeps it&lt;/a&gt;), stopping it &lt;a class="link" href="https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/" &gt;leaking through a log&lt;/a&gt; is another, and this is the third side of the triangle: once you&amp;rsquo;ve stored it and stopped it leaking, make sure you don&amp;rsquo;t &lt;em&gt;send&lt;/em&gt; it somewhere daft.&lt;/p&gt;</description></item><item><title>Redacting the secret you didn't know was in the string</title><link>https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/</link><pubDate>Sat, 18 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/</guid><description>&lt;img src="https://phpboyscout.uk/redacting-the-secret-you-didnt-know-was-in-the-string/cover-redacting-the-secret-you-didnt-know.png" alt="Featured image of post Redacting the secret you didn't know was in the string" /&gt;&lt;p&gt;Dammit! How did that get there?&lt;/p&gt;
&lt;p&gt;A log line that should never have existed. Not a password I&amp;rsquo;d carelessly printed, nothing as obvious as that. An upstream API handed me back an error, and it had quoted my own bearer token inside the message, and that error went straight into the logs the way errors do. I didn&amp;rsquo;t put the secret there. The error did. And I&amp;rsquo;d never have caught it by being careful, because being careful only protects you from the secrets you know you&amp;rsquo;re handling.&lt;/p&gt;
&lt;h2 id="the-easy-half-of-redaction"&gt;The easy half of redaction
&lt;/h2&gt;&lt;p&gt;Hiding the secrets you know about is the part everyone does. You&amp;rsquo;ve got an API key field, a password flag, so you mask them at the point you print them. &lt;code&gt;key=****&lt;/code&gt;. Done, and it feels like you&amp;rsquo;ve solved redaction, when really you&amp;rsquo;ve solved the half that was never going to bite you.&lt;/p&gt;
&lt;h2 id="the-half-that-bites"&gt;The half that bites
&lt;/h2&gt;&lt;p&gt;The secrets that escape are the ones that arrive inside strings you don&amp;rsquo;t control. An upstream service echoes your token back in a &lt;code&gt;401&lt;/code&gt; body. A connection string with the password in the userinfo, &lt;code&gt;https://user:pass@host&lt;/code&gt;, lands in a debug line. A library stringifies a whole request, headers and all, for a &amp;ldquo;helpful&amp;rdquo; trace. You cannot field-mask a secret you didn&amp;rsquo;t know was in the string, because you never watched it go in.&lt;/p&gt;
&lt;h2 id="you-cant-register-a-value-you-never-had-so-match-the-shape"&gt;You can&amp;rsquo;t register a value you never had, so match the shape
&lt;/h2&gt;&lt;p&gt;This is the bit I got wrong in my own head at first. I assumed redaction meant handing it the secrets I was holding so it could watch for them. But the dangerous secrets are exactly the ones I&amp;rsquo;m &lt;em&gt;not&lt;/em&gt; holding a copy of. So &lt;code&gt;pkg/redact&lt;/code&gt; doesn&amp;rsquo;t keep a registry of your values at all. It knows what secrets &lt;em&gt;look like&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/redact/redact.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/redact/redact.go&lt;/code&gt;&lt;/a&gt; carries a set of RE2 patterns: a credential in URL userinfo, an &lt;code&gt;Authorization:&lt;/code&gt; header sitting in free text, query-string credentials, and the well-known provider prefixes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;prefixPatterns&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Regexp&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`sk-[A-Za-z0-9_\-]{16,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// OpenAI / Anthropic-style&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`ghp_[A-Za-z0-9]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// GitHub PAT classic&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`github_pat_[A-Za-z0-9_]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// GitHub fine-grained PAT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`xox[baprs]-[A-Za-z0-9-]{10,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Slack&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`AIza[A-Za-z0-9_\-]{30,}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// Google API key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;regexp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`AKIA[A-Z0-9]{16}`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// AWS access key ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Run any string through &lt;code&gt;redact.String&lt;/code&gt; and an OpenAI key, a GitHub token or an AWS access key ID gets caught wherever it&amp;rsquo;s hiding, in an error you didn&amp;rsquo;t write, in a URL, in a stack trace, because each has a recognisable shape. For the secrets that don&amp;rsquo;t announce themselves with a prefix there&amp;rsquo;s a fuzzy fallback: any opaque alphanumeric run of 41 characters or more. The 41 is chosen on purpose, to clear UUIDs (36), MD5 (32) and git SHA-1 (40) without flagging them, while accepting that a SHA-256 (64) will trip it. A deliberate, documented trade rather than a magic number.&lt;/p&gt;
&lt;h2 id="where-it-runs"&gt;Where it runs
&lt;/h2&gt;&lt;p&gt;At the boundary where a string leaves for somewhere you can&amp;rsquo;t reach back into. The telemetry backend runs every event argument and error message through &lt;code&gt;redact.String&lt;/code&gt; before it emits anything (&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/telemetry.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/telemetry/telemetry.go&lt;/code&gt;&lt;/a&gt;), and both telemetry and HTTP logging drop the value of any header &lt;code&gt;redact&lt;/code&gt; flags as sensitive. It doesn&amp;rsquo;t matter which code path produced the string, or whether you even wrote that path; everything goes through the same gate and gets the same scrub.&lt;/p&gt;
&lt;p&gt;rust-tool-base&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-redact/src/lib.rs" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rtb-redact&lt;/code&gt;&lt;/a&gt; crate takes the same shape-matching approach: regex patterns, the same family of well-known provider prefixes, and an &lt;code&gt;is_sensitive_header&lt;/code&gt; check for header values.&lt;/p&gt;
&lt;h2 id="a-realistic-limit"&gt;A realistic limit
&lt;/h2&gt;&lt;p&gt;It isn&amp;rsquo;t a force field. A secret with no recognisable shape, shorter than the fallback threshold, will sail through. You cannot redact what you cannot recognise. But the leak that actually keeps happening isn&amp;rsquo;t some exotic unknown, it&amp;rsquo;s a well-known token turning up in a place you didn&amp;rsquo;t expect, and a shape-matcher sitting at the edge catches exactly that, &lt;em&gt;including secrets you never told it about&lt;/em&gt;. Which is the one thing registering your own values could never have done. Storing the key safely is a separate job, &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;where a CLI keeps it&lt;/a&gt;; this is about making sure that, having stored it, it doesn&amp;rsquo;t quietly fall out through a log.&lt;/p&gt;</description></item><item><title>I had the framework audited: every finding was the same shape</title><link>https://phpboyscout.uk/every-finding-was-the-same-shape/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/every-finding-was-the-same-shape/</guid><description>&lt;img src="https://phpboyscout.uk/every-finding-was-the-same-shape/cover-every-finding-was-the-same-shape.png" alt="Featured image of post I had the framework audited: every finding was the same shape" /&gt;&lt;p&gt;When a real security audit lands back in your inbox, the temptation is to read it as a shopping list of unrelated mistakes. Fix one, fix the next, tick them off, move on. I did exactly that the first time. The second time, I noticed something far more useful: the findings weren&amp;rsquo;t scattered at all. They clustered. Almost every one was the same sentence with the nouns swapped out.&lt;/p&gt;
&lt;h2 id="findings-cluster-they-dont-scatter"&gt;Findings cluster, they don&amp;rsquo;t scatter
&lt;/h2&gt;&lt;p&gt;When you get a real security audit back, the instinct is to read it as a list of unrelated mistakes. Finding 1, unrelated to Finding 2, unrelated to Finding 3. Triage each, fix each, move on.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s not what the go-tool-base audits looked like once I stopped reading them as a list. The findings &lt;em&gt;clustered&lt;/em&gt;. Strip away the specifics and almost every one was the same sentence with the nouns swapped: &lt;em&gt;untrusted input reaches a powerful operation, and nothing checks it in between.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That reframe is worth more than any individual fix, because it turns &amp;ldquo;we patched some bugs&amp;rdquo; into &amp;ldquo;we know where to look next time&amp;rdquo;. A framework&amp;rsquo;s attack surface isn&amp;rsquo;t spread evenly. It&amp;rsquo;s concentrated at the &lt;em&gt;boundaries&lt;/em&gt;: the handful of points where data from outside (a config file, a command-line flag, something typed into a TUI, an HTTP response) flows into machinery that can be made to misbehave. Audit the boundaries and you&amp;rsquo;ve audited most of the risk. Three examples make the pattern obvious.&lt;/p&gt;
&lt;h2 id="boundary-one-a-regex-compiler"&gt;Boundary one: a regex compiler
&lt;/h2&gt;&lt;p&gt;Somewhere in the tool, a user-supplied string gets compiled into a regular expression. A search pattern typed into the docs browser, a filter from a config file. Feeding user input to &lt;code&gt;regexp.Compile&lt;/code&gt; feels harmless. It&amp;rsquo;s just pattern matching, after all.&lt;/p&gt;
&lt;p&gt;It isn&amp;rsquo;t quite harmless. A regular expression is a tiny program, and some tiny programs are catastrophically slow. A pattern with the wrong kind of nested repetition can take exponential time to evaluate against a modestly hostile input. That&amp;rsquo;s the class of bug known as ReDoS. A user, or something feeding the user&amp;rsquo;s config, hands you a pathological pattern and your tool wedges, burning a whole core, on what looked for all the world like a search box.&lt;/p&gt;
&lt;p&gt;The fix isn&amp;rsquo;t to ban user-supplied regexes. It&amp;rsquo;s to stop treating &amp;ldquo;compile this string&amp;rdquo; as free. go-tool-base routes any regex whose pattern came from outside the binary through a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/regexutil/compile.go#L48" target="_blank" rel="noopener"
 &gt;&lt;code&gt;regexutil.CompileBounded&lt;/code&gt;&lt;/a&gt; helper. It caps the pattern length and puts a hard timeout on compilation. A pattern known at build time can still use plain &lt;code&gt;regexp.MustCompile&lt;/code&gt;, because that isn&amp;rsquo;t a boundary, it&amp;rsquo;s a constant. The discipline only applies where the input genuinely crosses in.&lt;/p&gt;
&lt;h2 id="boundary-two-a-url-opener"&gt;Boundary two: a URL opener
&lt;/h2&gt;&lt;p&gt;The tool needs to open a URL in the user&amp;rsquo;s browser, a docs link or an OAuth flow. Under the hood that&amp;rsquo;s the OS handler: &lt;code&gt;xdg-open&lt;/code&gt;, or &lt;code&gt;open&lt;/code&gt;, or &lt;code&gt;rundll32&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Now ask where the URL came from. If any part of it is influenced by config, by a server response, by user input, then &amp;ldquo;open this URL&amp;rdquo; has quietly become &amp;ldquo;ask the operating system to do something with an attacker-influenced string&amp;rdquo;. A &lt;code&gt;file://&lt;/code&gt; URL. A &lt;code&gt;javascript:&lt;/code&gt; URL. Something with control characters smuggled into it. The browser-open was never the dangerous part. The &lt;em&gt;unvalidated string&lt;/em&gt; was.&lt;/p&gt;
&lt;p&gt;So go-tool-base funnels every URL-open through one package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/browser/browser.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/browser&lt;/code&gt;&lt;/a&gt;, and that package is a gate. It enforces an allowlist of schemes (&lt;code&gt;https&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;mailto&lt;/code&gt;, and nothing else), bounds the length, and rejects control characters before the OS ever sees the string. The rule that makes it stick is that nothing else is allowed to call the OS handler directly. One door, and the door has a lock. A scattered capability with no chokepoint can&amp;rsquo;t be secured; a capability that &lt;em&gt;has&lt;/em&gt; a chokepoint can. (You&amp;rsquo;ll have spotted the &amp;ldquo;one door out&amp;rdquo; idea by now&amp;hellip; it&amp;rsquo;s the same instinct as the &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;single error handler&lt;/a&gt;, pointed at security instead of consistency.)&lt;/p&gt;
&lt;h2 id="boundary-three-a-log-sink"&gt;Boundary three: a log sink
&lt;/h2&gt;&lt;p&gt;This one&amp;rsquo;s the sneakiest, because it runs the wrong way round. The first two boundaries are about dangerous input coming &lt;em&gt;in&lt;/em&gt;. This one is about sensitive data leaking &lt;em&gt;out&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The tool handles credentials. It also logs, emits telemetry, and reports errors, and all three of those are &lt;em&gt;exit&lt;/em&gt; boundaries: places where strings leave the process for somewhere more persistent and more public, like a log aggregator, an analytics backend, an error tracker. If a token ever ends up in a string that flows to one of those, you haven&amp;rsquo;t logged an event, you&amp;rsquo;ve published a secret.&lt;/p&gt;
&lt;p&gt;The defence is &lt;code&gt;pkg/redact&lt;/code&gt;. Any free-form string heading for an observability surface goes through it first, and it strips the usual suspects: credentials in URL userinfo, sensitive query parameters, &lt;code&gt;Authorization&lt;/code&gt; headers, the well-known provider key prefixes (&lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;AIza&lt;/code&gt; and friends), long opaque tokens. The places most likely to leak, command arguments and error messages in telemetry, get it applied automatically rather than relying on every caller to remember.&lt;/p&gt;
&lt;p&gt;Same pattern as the other two. A boundary, and something standing on it checking what goes through.&lt;/p&gt;
&lt;h2 id="the-grunt-work"&gt;The grunt work
&lt;/h2&gt;&lt;p&gt;None of these fixes is clever. There&amp;rsquo;s no exploit demo, no neat trick to show off. Bound a length. Check a scheme against an allowlist. Run a string through a redactor. The work was almost entirely in &lt;em&gt;noticing the boundary existed&lt;/em&gt;, and then making sure everything routes through the one checked path instead of dotting raw calls all over the codebase.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the actual lesson of a security audit, and it&amp;rsquo;s why the cluster reframe matters. The value wasn&amp;rsquo;t the dozen-or-so individual fixes. It was learning that the next risk will be at a boundary too, the next place untrusted input meets a powerful operation with nothing in between, and that the job is to find those points and put a single, mandatory, checked door on each.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;A security audit of a CLI framework reads like a list of unrelated bugs and isn&amp;rsquo;t one. go-tool-base&amp;rsquo;s findings nearly all reduced to the same shape: untrusted input reaching a powerful operation unchecked. A regex compiler that needed a length and time bound (&lt;code&gt;regexutil.CompileBounded&lt;/code&gt;). A URL opener that needed a scheme allowlist and a single chokepoint (&lt;code&gt;pkg/browser&lt;/code&gt;). Log and telemetry sinks that needed credentials redacted on the way out (&lt;code&gt;pkg/redact&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The fixes were structural and dull, which is exactly right. Find your boundaries (config, flags, TUI input, network responses, log and telemetry sinks), give each one a single mandatory checked path, and you&amp;rsquo;ve spent your audit effort where the risk actually lives.&lt;/p&gt;</description></item><item><title>A mutex on a flag nobody writes twice</title><link>https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/</guid><description>&lt;img src="https://phpboyscout.uk/a-mutex-on-a-flag-nobody-writes-twice/cover-a-mutex-on-a-flag-nobody-writes-twice.png" alt="Featured image of post A mutex on a flag nobody writes twice" /&gt;&lt;p&gt;&amp;ldquo;Why is there a mutex around a boolean that only ever gets set once?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a fair question, and I&amp;rsquo;d half-asked it of myself before someone asked it of me. The answer turns out to be written, in as many words, in a code comment I&amp;rsquo;ve grown rather fond of.&lt;/p&gt;
&lt;h2 id="the-registry-and-its-one-way-latch"&gt;The registry and its one-way latch
&lt;/h2&gt;&lt;p&gt;go-tool-base keeps a feature registry: the initialisers, sub-commands, flags and checks that each feature adds to the CLI. Features register themselves into it at startup, from &lt;code&gt;init()&lt;/code&gt;, before &lt;code&gt;main&lt;/code&gt; runs. Once everything&amp;rsquo;s wired, the framework calls &lt;code&gt;SealRegistry()&lt;/code&gt; and the registry latches shut. Any &lt;code&gt;Register&lt;/code&gt; call after that point panics, on purpose, because a sub-command or flag that turns up &lt;em&gt;after&lt;/em&gt; the CLI has parsed its arguments is a bug I want to hear about at once, not discover three releases later.&lt;/p&gt;
&lt;p&gt;So there&amp;rsquo;s a &lt;code&gt;registrySealed&lt;/code&gt; bool. It starts &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;SealRegistry&lt;/code&gt; flips it to &lt;code&gt;true&lt;/code&gt; exactly once in normal operation, nothing flips it back outside of tests, and it&amp;rsquo;s read on every registration attempt. Written once, read many. The textbook shape of &amp;ldquo;you don&amp;rsquo;t need a lock for this.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="except-the-comment-disagrees-on-purpose"&gt;Except the comment disagrees, on purpose
&lt;/h2&gt;&lt;p&gt;Here is the actual declaration, in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/registry.go#L46-54" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/setup/registry.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// registryMu protects globalRegistry and registrySealed. Acquired for write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// by all Register* and Reset/Seal helpers; acquired for read by all Get*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// accessors. The mutex is required for memory visibility of registrySealed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// across goroutines, not only mutual exclusion on the maps.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;registryMu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RWMutex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="nx"&gt;registrySealed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That last sentence is the entire post. The mutex has an obvious day job: the registry is a clutch of maps that get appended to during registration, and concurrent appends need genuine mutual exclusion. &lt;code&gt;registrySealed&lt;/code&gt; could have just hitched a ride on that lock and nobody would have thought twice. But the comment goes out of its way to say the lock is &lt;em&gt;also required&lt;/em&gt; for the flag, for visibility, not only exclusion.&lt;/p&gt;
&lt;h2 id="why-a-write-once-bool-still-needs-the-lock"&gt;Why a write-once bool still needs the lock
&lt;/h2&gt;&lt;p&gt;The Go memory model makes no promise that a goroutine reading &lt;code&gt;registrySealed&lt;/code&gt; will ever see the write &lt;code&gt;SealRegistry&lt;/code&gt; made, unless there is a happens-before relationship between them. No synchronisation, no guarantee. A reader can sit there seeing &lt;code&gt;false&lt;/code&gt; long after the seal happened on another goroutine, because the compiler may cache the read and the CPU may serve it from a core-local view. And a concurrent read and write of the same variable, with nothing ordering them, isn&amp;rsquo;t &amp;ldquo;probably fine&amp;rdquo;; it&amp;rsquo;s a data race, which Go defines as undefined behaviour.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;But registration is single-threaded, it&amp;rsquo;s all &lt;code&gt;init()&lt;/code&gt;.&amp;rdquo; It was, right up until we wanted the tests to run in parallel. This lock exists because of a deliberate campaign to restore &lt;code&gt;t.Parallel()&lt;/code&gt; across the codebase after a stack of races forced us to drop it (the same campaign that &lt;a class="link" href="https://phpboyscout.uk/the-test-mocking-pattern-that-races/" &gt;retired the package-level mocking hooks&lt;/a&gt;). Tests build, register, seal and reset this registry from parallel goroutines. The instant that&amp;rsquo;s true, the seal check has to stay correct &lt;em&gt;while racing&lt;/em&gt;, because the very thing it guards against is concurrency. So reads take &lt;code&gt;registryMu.RLock&lt;/code&gt;, the write takes &lt;code&gt;registryMu.Lock&lt;/code&gt;, and now there&amp;rsquo;s a happens-before edge: anyone who acquires the lock after &lt;code&gt;SealRegistry&lt;/code&gt; released it is guaranteed to see &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="what-the-lock-is-actually-for"&gt;What the lock is actually for
&lt;/h2&gt;&lt;p&gt;It isn&amp;rsquo;t there to stop two goroutines both sealing the registry. There&amp;rsquo;s only ever the one seal. It&amp;rsquo;s there so that every reader can trust what it reads. A value written exactly once is precisely the case where you&amp;rsquo;re most tempted to skip the synchronisation, and precisely the case where skipping it can leave a reader legally staring at the stale value for good. The comment spells it out so that the next person to glance at &lt;code&gt;registrySealed&lt;/code&gt;, think &amp;ldquo;that clearly doesn&amp;rsquo;t need a lock,&amp;rdquo; and reach for the delete key, reads the sentence first.&lt;/p&gt;
&lt;p&gt;(There&amp;rsquo;s a sibling &lt;code&gt;sealed&lt;/code&gt; flag in the middleware registry that follows the identical pattern, for the identical reason.)&lt;/p&gt;</description></item><item><title>The test-mocking pattern that races</title><link>https://phpboyscout.uk/the-test-mocking-pattern-that-races/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-test-mocking-pattern-that-races/</guid><description>&lt;img src="https://phpboyscout.uk/the-test-mocking-pattern-that-races/cover-the-test-mocking-pattern-that-races.png" alt="Featured image of post The test-mocking pattern that races" /&gt;&lt;p&gt;I&amp;rsquo;m going to tell you about a bug go-tool-base shipped, because it&amp;rsquo;s one of those bugs that&amp;rsquo;s so reasonable-looking you&amp;rsquo;ll find it in textbooks, conference talks, and an awful lot of otherwise excellent Go code. We had it too. It passed every test on my laptop, every single time, and then quietly fell over on CI while blaming an innocent bystander.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the classic Go trick for mocking a dependency, and it races.&lt;/p&gt;
&lt;h2 id="a-pattern-that-looks-completely-reasonable"&gt;A pattern that looks completely reasonable
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s a thing you need to do constantly in Go tests: stop a function from really shelling out. It calls &lt;code&gt;exec.LookPath&lt;/code&gt; to find a binary, or &lt;code&gt;exec.Command&lt;/code&gt; to run one, and your test very much does not want it touching the real &lt;code&gt;$PATH&lt;/code&gt; or spawning a real process.&lt;/p&gt;
&lt;p&gt;The Go community has a well-worn answer. Hoist the function into a package-level variable, call &lt;em&gt;that&lt;/em&gt;, and let tests reassign it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// production code&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;findTool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;execLookPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;sometool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// test&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;TestFindTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;testing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;execLookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/fake/path&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...assert...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It&amp;rsquo;s tidy. No interface to thread through, no constructor to change. You&amp;rsquo;ll find it in a great deal of Go code, including some very respectable Go code indeed. go-tool-base had it too.&lt;/p&gt;
&lt;p&gt;And it works. It works on your machine, it works in code review, it works the first hundred times CI runs it. Which is precisely what makes it dangerous, because it&amp;rsquo;s wrong, and it&amp;rsquo;s just been biding its time.&lt;/p&gt;
&lt;h2 id="add-one-line-and-it-detonates"&gt;Add one line and it detonates
&lt;/h2&gt;&lt;p&gt;Go&amp;rsquo;s &lt;code&gt;t.Parallel()&lt;/code&gt; is more or less free performance. Mark your tests with it and the runner overlaps them instead of plodding through one at a time. On a package with a few hundred tests it&amp;rsquo;s a real, worthwhile speed-up, so naturally you reach for it.&lt;/p&gt;
&lt;p&gt;Now picture two tests, both using the pattern above, both marked &lt;code&gt;t.Parallel()&lt;/code&gt;. They run concurrently. Test A assigns its fake to &lt;code&gt;execLookPath&lt;/code&gt;. Test B assigns &lt;em&gt;its&lt;/em&gt; fake to &lt;code&gt;execLookPath&lt;/code&gt;. Test A reads &lt;code&gt;execLookPath&lt;/code&gt; expecting its own fake. Two goroutines, one variable, writes and reads with nothing synchronising them. That&amp;rsquo;s a textbook data race, and the textbook is right: the behaviour is undefined. Test A might see B&amp;rsquo;s fake. The deferred restore might land in the wrong order and leave the variable pointing at a fake after both tests have finished, poisoning a third one for good measure.&lt;/p&gt;
&lt;p&gt;The truly nasty part is the &lt;em&gt;intermittency&lt;/em&gt;. Whether the race actually bites depends on goroutine scheduling, which depends on machine load and core count. Your laptop running eight tests at once might never lose the coin-toss. A CI runner under load, scheduling differently, loses it and fails a test that has nothing obviously to do with the change in the commit. You re-run the pipeline, it passes, everyone shrugs and moves on. A test suite that fails one run in twenty trains your team to ignore it, and an ignored CI failure is worse than no CI at all.&lt;/p&gt;
&lt;p&gt;I can tell you this one from direct, slightly embarrassed experience, because go-tool-base shipped exactly this bug and CI caught it the honest way: green on the laptop, red on the runner, with the failure cheerfully pointing at innocent bystander tests rather than the global that was actually the culprit. &lt;code&gt;go test -race&lt;/code&gt; will name it for you if you crank the parallelism up high enough to lose the toss reliably&amp;hellip; but you have to go looking, and you only go looking once it&amp;rsquo;s already ruined an afternoon.&lt;/p&gt;
&lt;h2 id="the-fix-isnt-synchronisation-its-structure"&gt;The fix isn&amp;rsquo;t synchronisation, it&amp;rsquo;s structure
&lt;/h2&gt;&lt;p&gt;The instinct is to slap a mutex around the variable. Resist it. A mutex makes the race &lt;em&gt;defined&lt;/em&gt;, but it doesn&amp;rsquo;t make the design any good. You&amp;rsquo;ve still got global mutable state, you&amp;rsquo;ve just queued the fight instead of cancelling it. And tests that serialise on a shared lock aren&amp;rsquo;t really parallel any more, so you&amp;rsquo;ve also handed back the speed-up you came for in the first place.&lt;/p&gt;
&lt;p&gt;The real fix is to not have a shared variable at all. The dependency was always an &lt;em&gt;input&lt;/em&gt; to the code; the package-level var was just a way of avoiding saying so out loud. So say it. Inject it.&lt;/p&gt;
&lt;p&gt;A struct field:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Finder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;lookPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// defaults to exec.LookPath&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Finder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;sometool&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Or a functional option, if you&amp;rsquo;d rather keep the zero value clean. Either way, each test constructs its &lt;em&gt;own&lt;/em&gt; &lt;code&gt;Finder&lt;/code&gt; with its &lt;em&gt;own&lt;/em&gt; fake. There&amp;rsquo;s no shared variable, so there&amp;rsquo;s no race, and &lt;code&gt;t.Parallel()&lt;/code&gt; is free again because the tests genuinely don&amp;rsquo;t touch each other.&lt;/p&gt;
&lt;p&gt;go-tool-base wrote this straight into its standing rules: no package-level mocking hooks, full stop. Dependencies come in through struct fields, functional options, or config fields. (The same injection discipline that makes &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt; so testable, applied one rung further down.) And to stop everyone hand-rolling the same &lt;code&gt;exec&lt;/code&gt; fakes, there&amp;rsquo;s a small internal package, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/exectest/exectest.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;internal/exectest&lt;/code&gt;&lt;/a&gt;, with ready-made &lt;code&gt;LookPath&lt;/code&gt; and &lt;code&gt;CommandContext&lt;/code&gt; doubles you construct per-test. The pattern is gone, and the door it came in through is shut.&lt;/p&gt;
&lt;h2 id="the-rule-worth-taking-away"&gt;The rule worth taking away
&lt;/h2&gt;&lt;p&gt;A package-level variable that tests reassign is shared mutable state. It reads as a harmless convenience because in a single-threaded test run it behaves like one. &lt;code&gt;t.Parallel()&lt;/code&gt; is the thing that reveals it was never harmless, only unobserved.&lt;/p&gt;
&lt;p&gt;The general lesson is older than Go: &lt;strong&gt;if a value is an input to your code, make it an input.&lt;/strong&gt; Smuggling it in as a global is borrowing test-time convenience against a debt that comes due, with interest, the day someone wants their tests to run in parallel. Pay cash. Inject the dependency.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Mocking via a reassignable package-level variable is a beloved Go shortcut and a latent data race. It survives because single-threaded test runs hide it; &lt;code&gt;t.Parallel()&lt;/code&gt; exposes it as intermittent, bystander-blaming CI flake that&amp;rsquo;s miserable to trace. A mutex only makes the bad design &lt;em&gt;defined&lt;/em&gt;. The fix is structural: inject the dependency as a struct field or functional option, so each test owns its own double and there&amp;rsquo;s no shared state to race over. go-tool-base banned the global-hook pattern outright and ships &lt;code&gt;internal/exectest&lt;/code&gt; so nobody&amp;rsquo;s tempted back to it.&lt;/p&gt;
&lt;p&gt;If a piece of code depends on something, let it &lt;em&gt;say&lt;/em&gt; so in its signature. Your future self, staring at a CI failure that flatly refuses to reproduce, will thank you.&lt;/p&gt;</description></item><item><title>OpenSSF Scorecard graded my supply chain</title><link>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</guid><description>&lt;img src="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/cover-openssf-scorecard-graded-my-supply-chain.png" alt="Featured image of post OpenSSF Scorecard graded my supply chain" /&gt;&lt;p&gt;I turned OpenSSF Scorecard on expecting a pat on the head. go-tool-base is a security-minded project, I&amp;rsquo;m careful, surely the robot would agree. The robot did not agree. It handed back a report card with a fair bit of red ink, and the most pointed finding on it wasn&amp;rsquo;t about my code at all. It was about me.&lt;/p&gt;
&lt;h2 id="a-linter-for-the-things-you-dont-call-code"&gt;A linter for the things you don&amp;rsquo;t call code
&lt;/h2&gt;&lt;p&gt;Scorecard is an automated set of checks that grades a repository&amp;rsquo;s supply-chain hygiene: are your CI dependencies pinned, are your workflow tokens least-privilege, is your branch protected, do commits get reviewed. It&amp;rsquo;s a linter, but pointed at the part of the project you don&amp;rsquo;t usually think of as code, the build and release machinery and the practices around them. And like any good linter, its value is mostly in catching the things you&amp;rsquo;d swear you&amp;rsquo;d already got right.&lt;/p&gt;
&lt;p&gt;Three of its findings were worth the price of admission on their own.&lt;/p&gt;
&lt;h2 id="pin-the-actions-you-dont-control"&gt;Pin the actions you don&amp;rsquo;t control
&lt;/h2&gt;&lt;p&gt;The first was about how go-tool-base&amp;rsquo;s GitHub Actions referenced other actions. Like nearly everyone, I&amp;rsquo;d written &lt;code&gt;uses: actions/checkout@v6&lt;/code&gt;. Scorecard doesn&amp;rsquo;t like that, and it&amp;rsquo;s right not to.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@v6&lt;/code&gt; is a &lt;em&gt;tag&lt;/em&gt;, and a tag is mutable. Whoever controls that action can move &lt;code&gt;v6&lt;/code&gt; to point at different code tomorrow, and your CI will pick it up silently on the next run. For an action that runs in a job holding your repository token, that&amp;rsquo;s a supply-chain hole the width of a barn door: compromise the tag, compromise every pipeline that trusts it. The fix is to pin to an immutable commit SHA, with the human-readable version left as a comment, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/5500b92" target="_blank" rel="noopener"
 &gt;which is exactly what I changed&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the action is frozen at bytes I reviewed. Dependabot still bumps the SHA when a real new version lands, so I get updates as reviewable pull requests rather than as silent tag movements. The pin doesn&amp;rsquo;t stop me updating. It stops me updating &lt;em&gt;without noticing&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="give-the-workflow-token-the-least-it-can-do"&gt;Give the workflow token the least it can do
&lt;/h2&gt;&lt;p&gt;The second finding was about permissions. My workflow declared its token permissions at the top, once, for the whole file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;security-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads as careful, and it&amp;rsquo;s still too broad, because top-level permissions apply to &lt;em&gt;every job in the workflow&lt;/em&gt;. A job that only needs to read the repo is now also holding &lt;code&gt;id-token: write&lt;/code&gt; and &lt;code&gt;security-events: write&lt;/code&gt;, for no reason other than that some &lt;em&gt;other&lt;/em&gt; job in the same file needed them. Scorecard rejects exactly this, and the fix is to default the whole workflow to read-only and grant write narrowly, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/c41e557" target="_blank" rel="noopener"
 &gt;in the job that actually needs it&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read-all&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write permissions moved down into the single job that uses them. It&amp;rsquo;s the same least-privilege instinct that runs through everything else in these projects, just applied to a CI token instead of an IAM role: a credential should be able to do the one thing it&amp;rsquo;s for, and nothing else, no matter how convenient the broad grant looked.&lt;/p&gt;
&lt;h2 id="the-finding-that-was-about-me"&gt;The finding that was about me
&lt;/h2&gt;&lt;p&gt;The third one stung, because there was no YAML to fix. Scorecard&amp;rsquo;s Code-Review check scores how consistently changes are reviewed before they land, and mine scored badly for the most embarrassing possible reason: I&amp;rsquo;d set up branch protection on &lt;code&gt;main&lt;/code&gt;, and then, being the solo maintainer in a hurry, I&amp;rsquo;d been merrily bypassing it to push straight to &lt;code&gt;main&lt;/code&gt; whenever it suited me.&lt;/p&gt;
&lt;p&gt;So I had a rule, written down and enforced by the platform, that I was personally and routinely ignoring. Scorecard noticed, totted up the unreviewed commits, and graded me on it. There&amp;rsquo;s something properly humbling about a robot reading your git history and pointing out that the person breaking your security policy most often is &lt;em&gt;you&lt;/em&gt;. The fix wasn&amp;rsquo;t code. It was going through a pull request like everyone else, even when &amp;ldquo;everyone else&amp;rdquo; is just me on a different day.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;OpenSSF Scorecard is a linter for your supply chain, and like any linter it&amp;rsquo;s most useful when it tells you something you were sure you&amp;rsquo;d already handled. It dinged go-tool-base for referencing actions by mutable tag instead of pinned SHA, for granting workflow-token write permissions at the top level where every job inherited them, and for a Code-Review score I&amp;rsquo;d earned fair and square by bypassing my own branch protection.&lt;/p&gt;
&lt;p&gt;The first two were quick, satisfying changes with a clear security story. The third was the one that stuck, because the tool I&amp;rsquo;d added to grade the project ended up grading the maintainer, and was entirely right to. Turn it on. Brace yourself a little.&lt;/p&gt;</description></item><item><title>Testing code that calls an LLM: yes, you actually can</title><link>https://phpboyscout.uk/testing-code-that-calls-an-llm/</link><pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/testing-code-that-calls-an-llm/</guid><description>&lt;img src="https://phpboyscout.uk/testing-code-that-calls-an-llm/cover-testing-code-that-calls-an-llm.png" alt="Featured image of post Testing code that calls an LLM: yes, you actually can" /&gt;&lt;p&gt;&amp;ldquo;You can&amp;rsquo;t test code that calls an AI.&amp;rdquo; I&amp;rsquo;ve heard it said with great confidence, and it&amp;rsquo;s half right, which is the most dangerous kind of right. You genuinely can&amp;rsquo;t assert on what a non-deterministic model says. But the model isn&amp;rsquo;t your code, and the bits sitting either side of it most certainly are.&lt;/p&gt;
&lt;h2 id="you-cant-test-ai-code"&gt;&amp;ldquo;You can&amp;rsquo;t test AI code&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s a fair worry. Your command calls an LLM. The LLM returns something slightly different every run. A test that asserts &lt;code&gt;response == &amp;quot;...&amp;quot;&lt;/code&gt; is broken before you&amp;rsquo;ve finished typing it. So the conclusion arrives quickly: the AI path can&amp;rsquo;t be tested, leave it uncovered.&lt;/p&gt;
&lt;p&gt;Which is a shame, because the AI call is usually the riskiest line in the whole command.&lt;/p&gt;
&lt;p&gt;The conclusion is also wrong. It mistakes &amp;ldquo;I can&amp;rsquo;t test the model&amp;rdquo; for &amp;ldquo;I can&amp;rsquo;t test my code&amp;rdquo;. The model is not your code. Your code is the two pieces sitting on either side of it.&lt;/p&gt;
&lt;h2 id="your-code-is-a-prompt-and-a-handler"&gt;Your code is a prompt and a handler
&lt;/h2&gt;&lt;p&gt;Strip the command down to what it actually does:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It builds a prompt. It assembles a system prompt, the user&amp;rsquo;s input, perhaps some context, and sends it.&lt;/li&gt;
&lt;li&gt;The model does something. This is not your code.&lt;/li&gt;
&lt;li&gt;It takes the response and does something with it. It parses it, branches on it, prints it, stores it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps one and three are entirely yours, and entirely deterministic. The same inputs build the same prompt and handle the same response the same way, every single time. That&amp;rsquo;s testable. Step two is the only part that isn&amp;rsquo;t, and step two was never yours to test in the first place.&lt;/p&gt;
&lt;p&gt;So the job is to pin step two to a known value, and then test one and three properly.&lt;/p&gt;
&lt;h2 id="test-the-prompt-snapshot-it"&gt;Test the prompt: snapshot it
&lt;/h2&gt;&lt;p&gt;Step one produces a prompt, and a prompt is just a string, which means you can pin it.&lt;/p&gt;
&lt;p&gt;Both frameworks lean on snapshot testing here. go-tool-base uses a golden-file approach: the prompt your code generates is recorded to a file, and the test re-generates it and compares against that file. rust-tool-base does the same with &lt;code&gt;insta&lt;/code&gt;, snapshotting the request body the client would send.&lt;/p&gt;
&lt;p&gt;The reason this matters is that the prompt is load-bearing and quietly easy to break. You refactor how context gets assembled. Without noticing, you&amp;rsquo;ve changed the wording, or the ordering, or dropped a line the model was leaning on. Nothing fails to compile. The behaviour just drifts, silently.&lt;/p&gt;
&lt;p&gt;A snapshot test catches exactly that. It fails, it shows you the diff between the old prompt and the new one, and it makes you stop and make a decision. Was this change intended? If yes, you accept the new snapshot and move on. If no, you&amp;rsquo;ve just caught a bug before it shipped. Either way the prompt never changes by accident, which for AI code is most of the battle.&lt;/p&gt;
&lt;h2 id="test-the-handler-mock-the-response"&gt;Test the handler: mock the response
&lt;/h2&gt;&lt;p&gt;Step three needs a response to handle, and in a unit test you don&amp;rsquo;t get that response from the real model. You supply it.&lt;/p&gt;
&lt;p&gt;go-tool-base ships &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/mocks/pkg/chat/ChatClient.go" target="_blank" rel="noopener"
 &gt;generated mocks for the &lt;code&gt;ChatClient&lt;/code&gt; interface&lt;/a&gt;. A test builds a mock client, tells it &amp;ldquo;when &lt;code&gt;Ask&lt;/code&gt; is called, return &lt;em&gt;this&lt;/em&gt; canned value&amp;rdquo;, and runs the command against it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;mockClient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mock_chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewMockChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;mockClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EXPECT&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Anything&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Anything&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AnythingOfType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;*main.Analysis&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;RunAndReturn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;critical&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Because &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the interface is only four methods&lt;/a&gt;, that mock is trivial to set up and complete by construction. rust-tool-base takes the same idea one layer down: HTTP-bound tests use &lt;code&gt;wiremock&lt;/code&gt;, which stands up a fake server returning a canned response body. The client makes a real HTTP request; it just goes to a fake endpoint the test controls.&lt;/p&gt;
&lt;p&gt;Either way, step two is now fixed to a value you chose, which makes step three deterministic. And that unlocks the tests that actually matter: given a malformed response, does the command fail gracefully? Given a rate-limit error, an empty answer, a field missing? Those are the cases a live model almost never hands you on demand, and a mock hands you every time, on the first run.&lt;/p&gt;
&lt;p&gt;This is, incidentally, the same discipline as &lt;a class="link" href="https://phpboyscout.uk/the-test-mocking-pattern-that-races/" &gt;the test-mocking work elsewhere in the framework&lt;/a&gt;: the dependency is injected, so the test gets to decide what it does.&lt;/p&gt;
&lt;h2 id="what-you-deliberately-dont-test"&gt;What you deliberately don&amp;rsquo;t test
&lt;/h2&gt;&lt;p&gt;One boundary worth stating. None of this tests whether the model gives &lt;em&gt;good&lt;/em&gt; answers. That question is real, but it&amp;rsquo;s a different activity (evaluations, run as their own suite) and not something to mix into the unit tests.&lt;/p&gt;
&lt;p&gt;The unit suite&amp;rsquo;s job is your code: that it builds a sound prompt, and that it handles every shape of response correctly, including the ugly ones. Keep that well away from &amp;ldquo;is the model clever today&amp;rdquo;. A unit test that depends on the model being clever is a unit test that fails when the weather changes, and a flaky test just teaches people to ignore the whole suite.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;Code that calls an LLM is testable; the model is not, and those are different statements. Your code is a prompt builder and a response handler, both deterministic, with the model sat in between.&lt;/p&gt;
&lt;p&gt;go-tool-base and rust-tool-base converge on the same approach. Snapshot the prompt, with golden files or &lt;code&gt;insta&lt;/code&gt;, so a refactor can&amp;rsquo;t change what you send without a test noticing. Mock the response, with generated &lt;code&gt;ChatClient&lt;/code&gt; mocks or a &lt;code&gt;wiremock&lt;/code&gt; server, so tests run with no network and you can feed in the malformed and error cases a real model won&amp;rsquo;t reliably produce. Leave &amp;ldquo;are the answers any good&amp;rdquo; to a separate evaluation suite. Test the two halves you own, and the non-determinism in the middle stops being an excuse to leave the riskiest line uncovered.&lt;/p&gt;</description></item><item><title>The AI provider that isn't an API</title><link>https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/</link><pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/</guid><description>&lt;img src="https://phpboyscout.uk/the-ai-provider-that-isnt-an-api/cover-the-ai-provider-that-isnt-an-api.png" alt="Featured image of post The AI provider that isn't an API" /&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package puts five AI providers behind one interface. Four of them are exactly what you&amp;rsquo;d guess: HTTP calls to OpenAI, Claude, Gemini, and anything OpenAI-compatible. The fifth one isn&amp;rsquo;t an API at all. It shells out to a binary.&lt;/p&gt;
&lt;p&gt;That sounds like a slightly mad thing to want, right up until you&amp;rsquo;ve worked somewhere the network says no.&lt;/p&gt;
&lt;h2 id="the-fifth-provider-shells-out"&gt;The fifth provider shells out
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;chat&lt;/code&gt; package speaks to five providers through one &lt;code&gt;ChatClient&lt;/code&gt; interface. Four of them are what you&amp;rsquo;d expect: HTTP requests to OpenAI, to Claude, to Gemini, to any OpenAI-compatible endpoint. The tool author picks one in config, and the rest of the code never knows the difference.&lt;/p&gt;
&lt;p&gt;The fifth, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/claude_local.go#L18" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ProviderClaudeLocal&lt;/code&gt;&lt;/a&gt;, is different in kind. It doesn&amp;rsquo;t make an HTTP request at all. It shells out. It runs the &lt;code&gt;claude&lt;/code&gt; CLI binary as a child process, passes the prompt in, and reads the answer back from the binary&amp;rsquo;s output.&lt;/p&gt;
&lt;p&gt;That sounds like an odd thing to want until you&amp;rsquo;ve been stuck in the environment it was built for.&lt;/p&gt;
&lt;h2 id="why-youd-want-that"&gt;Why you&amp;rsquo;d want that
&lt;/h2&gt;&lt;p&gt;Picture a corporate network with its egress locked right down. Outbound HTTPS to &lt;code&gt;api.anthropic.com&lt;/code&gt; is blocked by policy. A tool built on go-tool-base that uses AI would simply fall over there. It tries to reach the API, there&amp;rsquo;s no route, and that&amp;rsquo;s the end of the feature.&lt;/p&gt;
&lt;p&gt;But the developer at that machine has the &lt;code&gt;claude&lt;/code&gt; CLI installed, and has run &lt;code&gt;claude login&lt;/code&gt;. That binary is permitted. It&amp;rsquo;s an approved, managed tool, and it has its own sanctioned path out. The direct API call is blocked; the &lt;code&gt;claude&lt;/code&gt; command is not.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is what bridges those two facts. If your tool&amp;rsquo;s AI calls go &lt;em&gt;through&lt;/em&gt; that already-blessed binary instead of straight at the API, they work, in an environment where the direct call cannot. That&amp;rsquo;s the whole reason the provider exists. It isn&amp;rsquo;t faster (a real API call has lower latency) and it isn&amp;rsquo;t more capable. It&amp;rsquo;s for the place where the API call simply isn&amp;rsquo;t an option, and &amp;ldquo;isn&amp;rsquo;t an option&amp;rdquo; is a surprisingly common place to find yourself inside a large organisation.&lt;/p&gt;
&lt;h2 id="what-it-costs"&gt;What it costs
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth being straight about the trade, because &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is the reduced-capability provider.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t do tool calling. It doesn&amp;rsquo;t do parallel tools. It doesn&amp;rsquo;t stream. Those need a live, structured connection to the model&amp;rsquo;s API, and a subprocess that runs once and prints an answer is not that. What it &lt;em&gt;does&lt;/em&gt; support is plain chat and structured output, the latter through the binary&amp;rsquo;s own &lt;code&gt;--json-schema&lt;/code&gt; flag.&lt;/p&gt;
&lt;p&gt;So the positioning, and the package&amp;rsquo;s documentation says exactly this, is: prefer the API providers when you can reach them, because they&amp;rsquo;re lower latency and feature-complete. Reach for &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; when API access is restricted. You accept the narrower capability set as the price of working at all. For a tool whose AI feature is &amp;ldquo;answer a question&amp;rdquo; or &amp;ldquo;return a structured analysis&amp;rdquo;, that price is often nothing you&amp;rsquo;d even notice. For one built on an agentic tool-calling loop, it&amp;rsquo;s a real limitation, and you&amp;rsquo;d know to expect it.&lt;/p&gt;
&lt;h2 id="how-it-stays-behind-the-same-interface"&gt;How it stays behind the same interface
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the part that makes it pleasant rather than a special case to maintain. Despite being a subprocess and not an API, &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is still a &lt;code&gt;ChatClient&lt;/code&gt;. Your feature code calls &lt;code&gt;Chat&lt;/code&gt; and &lt;code&gt;Ask&lt;/code&gt; exactly the way it would for any other provider.&lt;/p&gt;
&lt;p&gt;Everything that makes a subprocess provider awkward stays inside the provider. Spawning the binary, feeding it the prompt, parsing its output, capturing &lt;code&gt;stderr&lt;/code&gt; and surfacing it when the binary exits non-zero, and threading multi-turn continuity through session identifiers passed back on the next call with &lt;code&gt;--resume&lt;/code&gt;: all of that is the provider&amp;rsquo;s problem, and all of it sits behind the interface. The code in your tool that uses AI doesn&amp;rsquo;t know, and has no way to find out, that this particular provider is a child process rather than an HTTPS call.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a unified interface genuinely earning its place. It&amp;rsquo;s easy to put a uniform face on four things that already work the same way underneath. The real test of the abstraction is whether something that works in a &lt;em&gt;completely&lt;/em&gt; different way, a subprocess instead of a socket, can still slot in without the caller changing a line. Here it can. You swap one config value, and a tool that talked to an API now talks through a binary, and nothing downstream so much as blinks.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package puts five providers behind one &lt;code&gt;ChatClient&lt;/code&gt; interface, and &lt;code&gt;ProviderClaudeLocal&lt;/code&gt; is the one that isn&amp;rsquo;t an API. It runs the locally installed, pre-authenticated &lt;code&gt;claude&lt;/code&gt; CLI as a subprocess.&lt;/p&gt;
&lt;p&gt;It exists for the locked-down environment where outbound HTTPS to the AI API is blocked but the &lt;code&gt;claude&lt;/code&gt; binary is allowed: there, AI features keep working where a direct call would fail. The trade is a narrower capability set (no tool calling, no streaming, plain chat and structured output only) so you prefer the API providers when you can reach them and fall back to this when you can&amp;rsquo;t. And because it&amp;rsquo;s still a &lt;code&gt;ChatClient&lt;/code&gt;, all the subprocess machinery stays hidden, and your code uses it without knowing it&amp;rsquo;s there. That last part is the real test of an abstraction: a provider that works in an entirely different way still slots in unchanged.&lt;/p&gt;</description></item><item><title>AI conversations you can resume</title><link>https://phpboyscout.uk/ai-conversations-you-can-resume/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ai-conversations-you-can-resume/</guid><description>&lt;img src="https://phpboyscout.uk/ai-conversations-you-can-resume/cover-ai-conversations-you-can-resume.png" alt="Featured image of post AI conversations you can resume" /&gt;&lt;p&gt;An AI conversation is, fundamentally, its own history. The model&amp;rsquo;s next answer depends on everything said so far. And a CLI tool, by its very nature, forgets everything the moment it exits. Put those two facts together and you get the problem: run an AI command, exit, run it again, and you&amp;rsquo;re talking to someone who&amp;rsquo;s never met you.&lt;/p&gt;
&lt;h2 id="a-cli-forgets-everything"&gt;A CLI forgets everything
&lt;/h2&gt;&lt;p&gt;A long-running service keeps its state in memory for as long as it runs. A CLI tool doesn&amp;rsquo;t get that luxury. It starts, does one thing, exits. The next invocation is a brand-new process with no memory of the last one.&lt;/p&gt;
&lt;p&gt;For most commands that&amp;rsquo;s exactly right, and you wouldn&amp;rsquo;t want it any other way. But an AI conversation is a different kind of beast, because a conversation &lt;em&gt;is&lt;/em&gt; its history. The model&amp;rsquo;s next answer depends on everything said so far. Run an AI command, exit, run it again, and you&amp;rsquo;ve started a fresh conversation with someone who&amp;rsquo;s never met you. For an interactive assistant, or any AI workflow that unfolds across several invocations, that&amp;rsquo;s plainly the wrong behaviour. The user expects to pick up where they left off.&lt;/p&gt;
&lt;h2 id="save-and-restore"&gt;Save and restore
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;chat&lt;/code&gt; package handles this through a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/persistence.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;PersistentChatClient&lt;/code&gt;&lt;/a&gt; interface. Like streaming, it&amp;rsquo;s an optional capability discovered with a type assertion, sitting beside &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;the four-method core&lt;/a&gt; rather than bloating it. A client that supports persistence also satisfies this interface:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.(&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PersistentChatClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// store the snapshot somewhere&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A snapshot is a serialisable value that captures the conversation. You store it. Next run, you load it, &lt;code&gt;Restore&lt;/code&gt; it onto a fresh client, re-register your tools, and call &lt;code&gt;Chat&lt;/code&gt; again. &amp;ldquo;Where were we?&amp;rdquo; works, because the model is handed back the whole history.&lt;/p&gt;
&lt;h2 id="a-snapshot-is-opinionated-about-what-it-carries"&gt;A snapshot is opinionated about what it carries
&lt;/h2&gt;&lt;p&gt;The interesting part is what a snapshot does and doesn&amp;rsquo;t contain, because that&amp;rsquo;s a series of deliberate decisions.&lt;/p&gt;
&lt;p&gt;It carries the messages, the system prompt, the model name, and tool &lt;em&gt;metadata&lt;/em&gt;: the names, descriptions and parameter schemas of the tools that were registered.&lt;/p&gt;
&lt;p&gt;It does not carry tool &lt;em&gt;handlers&lt;/em&gt;. Handlers are code, not data; you can&amp;rsquo;t serialise a function meaningfully, so after a restore you re-register them with &lt;code&gt;SetTools&lt;/code&gt;. The snapshot remembers that a tool called &lt;code&gt;read_file&lt;/code&gt; existed and what its shape was; it doesn&amp;rsquo;t try to remember the Go function behind it.&lt;/p&gt;
&lt;p&gt;And it does not carry API tokens. This is the one to dwell on. A snapshot is a file. A file gets synced, backed up, copied between machines, attached to a support ticket by a user trying to be helpful. A snapshot that carried the API key would be a credential leak the moment it left the laptop it was made on. So the snapshot never contains a token, at all. On restore, the client picks the credential up again the ordinary way, from &lt;a class="link" href="https://phpboyscout.uk/where-should-a-cli-keep-your-api-keys/" &gt;the environment or the keychain&lt;/a&gt;. The conversation and the secret are kept in separate places on purpose, and only one of them is ever in the file.&lt;/p&gt;
&lt;h2 id="encrypted-at-rest-if-you-want-it"&gt;Encrypted at rest, if you want it
&lt;/h2&gt;&lt;p&gt;The package ships a &lt;code&gt;FileStore&lt;/code&gt; that writes snapshots as JSON files, with &lt;code&gt;0600&lt;/code&gt; permissions in a &lt;code&gt;0700&lt;/code&gt; directory, and it can encrypt them. Pass &lt;code&gt;WithEncryption&lt;/code&gt; a 32-byte key and snapshots are written with AES-256-GCM.&lt;/p&gt;
&lt;p&gt;That option exists because a conversation can hold sensitive content even when it holds no credential. The log a user pasted in for analysis, the source file they asked the model to review, the internal details tucked into their questions: none of that is an API key, and all of it might be something you&amp;rsquo;d rather not have sitting in plain JSON in a backup somewhere. Encryption at rest covers it.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;FileStore&lt;/code&gt; is also careful about the snapshot identifiers it&amp;rsquo;s handed. An ID has to be a canonical UUID, and the resolved file path is checked to lie inside the store directory, so a snapshot ID arriving from an untrusted source (a CLI flag, a request payload) can&amp;rsquo;t be bent into a path-traversal that reads or writes somewhere it shouldn&amp;rsquo;t. Persisting conversations adds a small filesystem surface, and the store treats it as exactly that.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;A CLI tool forgets everything between invocations, which is correct for most commands and wrong for an AI conversation, because a conversation is its history.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package lets you persist one. &lt;code&gt;PersistentChatClient&lt;/code&gt; saves a snapshot you can store and restore later, picking the conversation back up where it ended. The snapshot is deliberate about its contents: messages, system prompt and tool metadata yes; tool handlers no, because they&amp;rsquo;re code you re-register; API tokens never, because a snapshot is a file and a file travels. The built-in &lt;code&gt;FileStore&lt;/code&gt; can encrypt snapshots at rest with AES-256-GCM and validates snapshot IDs against path traversal. Resumable conversations, without the conversation file turning into a place secrets leak from.&lt;/p&gt;</description></item><item><title>An AI agent that has to make the build pass</title><link>https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/</link><pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/</guid><description>&lt;img src="https://phpboyscout.uk/an-ai-agent-that-has-to-make-the-build-pass/cover-an-ai-agent-that-has-to-make-the-build-pass.png" alt="Featured image of post An AI agent that has to make the build pass" /&gt;&lt;p&gt;Most AI code generation works on a charming little principle I&amp;rsquo;ll call generate-and-hope. The model writes the code, the model stops at the closing brace, and whether the thing actually compiles is left as an exercise for you. For a snippet you paste into an editor, fine. For a whole generated command, that&amp;rsquo;s just outsourcing the disappointment.&lt;/p&gt;
&lt;p&gt;go-tool-base does something I&amp;rsquo;m rather happier with: the AI has to make the build pass before it&amp;rsquo;s allowed to claim it&amp;rsquo;s done.&lt;/p&gt;
&lt;h2 id="generate-and-hope"&gt;Generate and hope
&lt;/h2&gt;&lt;p&gt;The usual shape of AI code generation is this. You ask for code, the model produces it, and the model&amp;rsquo;s job ends at the closing brace. Whether it compiles, whether the tests pass, whether the imports even resolve, none of that has been checked. The model produced something that &lt;em&gt;looks&lt;/em&gt; right. You find out whether it &lt;em&gt;is&lt;/em&gt; right when you build it.&lt;/p&gt;
&lt;p&gt;For a snippet you paste into an editor, that&amp;rsquo;s perfectly fine. The compiler tells you in a second. But go-tool-base&amp;rsquo;s generator, driven by &lt;code&gt;gtb generate command --script&lt;/code&gt; or &lt;code&gt;--prompt&lt;/code&gt;, produces a whole command: the implementation, its tests, the lot. &amp;ldquo;Generate and hope&amp;rdquo; at that scale means handing the user a project that may or may not build, and quietly making them the one who finds out which.&lt;/p&gt;
&lt;h2 id="drafting-is-only-step-one"&gt;Drafting is only step one
&lt;/h2&gt;&lt;p&gt;So the generator doesn&amp;rsquo;t stop at drafting. Writing the first version of the implementation and its tests is step one of two. Step two is an autonomous repair agent.&lt;/p&gt;
&lt;p&gt;Once the draft is on the filesystem, a separate agent takes over. It&amp;rsquo;s an LLM running in a loop, but a loop aimed at one narrow, checkable job: make this project build and pass its tests. It isn&amp;rsquo;t asked to be creative. It&amp;rsquo;s asked to get to green.&lt;/p&gt;
&lt;h2 id="a-fixed-set-of-tools-and-no-shell"&gt;A fixed set of tools, and no shell
&lt;/h2&gt;&lt;p&gt;The agent is not handed a shell. It&amp;rsquo;s given a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/agent/tools.go" target="_blank" rel="noopener"
 &gt;fixed, defined set of tools&lt;/a&gt; and nothing else. Three of them let it explore and edit the project: &lt;code&gt;list_dir&lt;/code&gt;, &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;write_file&lt;/code&gt;. Four of them let it verify the project:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;go_build&lt;/code&gt; runs the build and captures the compiler errors.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go_test&lt;/code&gt; runs the tests and captures the failures.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;go_get&lt;/code&gt; resolves a missing dependency.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;golangci_lint&lt;/code&gt; runs the project&amp;rsquo;s linter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That restriction is the design, not a limitation of it. The agent can&amp;rsquo;t delete arbitrary files, can&amp;rsquo;t reach the network, can&amp;rsquo;t run anything that isn&amp;rsquo;t on the list. It has exactly what it needs to make code compile and nothing it would need to do damage. Its file writes are confined to the project directory by an explicit path check, so even &lt;code&gt;write_file&lt;/code&gt; can&amp;rsquo;t go wandering up into &lt;code&gt;/etc&lt;/code&gt;. A coding agent you&amp;rsquo;d actually let near a filesystem is one whose abilities are an allowlist, not a denylist. (I keep coming back to that principle through this series&amp;hellip; safety as a boundary you draw, not a behaviour you hope for.)&lt;/p&gt;
&lt;h2 id="the-loop"&gt;The loop
&lt;/h2&gt;&lt;p&gt;The repair loop is a ReAct loop, the same reason-act-observe shape as &lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;the tool-calling loop&lt;/a&gt;, only this time pointed at a goal:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The draft is on disk.&lt;/li&gt;
&lt;li&gt;Verify: run &lt;code&gt;go_build&lt;/code&gt; and &lt;code&gt;go_test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If verification failed, read the error logs, the compiler error or the failing test.&lt;/li&gt;
&lt;li&gt;Reason about the cause: an undefined variable, a missing import, a wrong signature.&lt;/li&gt;
&lt;li&gt;Act: call &lt;code&gt;write_file&lt;/code&gt; to patch the code, or &lt;code&gt;go_get&lt;/code&gt; to add the dependency.&lt;/li&gt;
&lt;li&gt;Loop. Steps two to five repeat until the project is green, or the agent hits its bounded step limit.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;What makes this work is treating the error output as &lt;em&gt;feedback&lt;/em&gt; rather than as a failure to log and walk away from. A compiler error is the single most useful sentence you can hand a model that&amp;rsquo;s trying to fix code. It says what&amp;rsquo;s wrong, and usually where. The loop feeds it straight back in, and the model fixes against it.&lt;/p&gt;
&lt;h2 id="verification-changes-what-done-means"&gt;Verification changes what &amp;ldquo;done&amp;rdquo; means
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the real shift, and the agent&amp;rsquo;s own documentation puts it well: the agent &amp;ldquo;doesn&amp;rsquo;t just say it fixed a bug; it uses a Test tool to verify the fix before reporting success.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;A generate-and-hope model reports success when it finishes &lt;em&gt;writing&lt;/em&gt;. It has no idea whether the code works, and it isn&amp;rsquo;t really claiming otherwise. &amp;ldquo;Done&amp;rdquo; means &amp;ldquo;I produced text&amp;rdquo;. The repair agent reports success when &lt;code&gt;go_build&lt;/code&gt; and &lt;code&gt;go_test&lt;/code&gt; actually &lt;em&gt;pass&lt;/em&gt;. &amp;ldquo;Done&amp;rdquo; means &amp;ldquo;the build is green&amp;rdquo;. Those are two completely different claims, and only the second is worth anything to the person who asked for the command.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the line between an AI that&amp;rsquo;s a creative writer and an AI that&amp;rsquo;s a collaborator you can hand a task to. And when the agent can&amp;rsquo;t reach green, when it spends its whole step budget and the project is still broken, the generator fails safely: it leaves the best-attempt code in place, commented out so the project still compiles, and tells the user what to finish by hand. There&amp;rsquo;s also an &lt;code&gt;--agentless&lt;/code&gt; flag for anyone who&amp;rsquo;d rather have a plain single-shot retry than the multi-step agent. The default, though, is the agent, because the default should be code that&amp;rsquo;s been checked.&lt;/p&gt;
&lt;h2 id="where-this-leaves-us"&gt;Where this leaves us
&lt;/h2&gt;&lt;p&gt;Most AI code generation generates and hopes: the model writes code and the user discovers whether it works. For a whole generated command, that pushes a may-or-may-not-build project onto the user.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s generator drafts the command and then hands it to an autonomous repair agent. The agent has a fixed set of tools (explore and edit the project, build it, test it, lint it, fetch dependencies) and no shell at all, with file writes confined to the project directory. It runs a ReAct loop, reading each error and patching against it, until the build is green or it exhausts its steps. The point is what &amp;ldquo;done&amp;rdquo; comes to mean: not &amp;ldquo;the model finished writing&amp;rdquo;, but &amp;ldquo;the build passes&amp;rdquo;. Only one of those is a claim worth trusting.&lt;/p&gt;</description></item><item><title>Stop regex-ing the LLM's prose</title><link>https://phpboyscout.uk/stop-regexing-the-llms-prose/</link><pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/stop-regexing-the-llms-prose/</guid><description>&lt;img src="https://phpboyscout.uk/stop-regexing-the-llms-prose/cover-stop-regexing-the-llms-prose.png" alt="Featured image of post Stop regex-ing the LLM's prose" /&gt;&lt;p&gt;Ask an LLM a question and it hands you back prose. Lovely to read, miserable to program against. You wanted the one number buried in the middle of it, and now you&amp;rsquo;re writing a regular expression to fish a word out of three well-written paragraphs that phrase themselves slightly differently every single time you run them.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a much better way, and it&amp;rsquo;s the difference between forever interpreting an LLM and actually building on one.&lt;/p&gt;
&lt;h2 id="the-problem-with-a-paragraph"&gt;The problem with a paragraph
&lt;/h2&gt;&lt;p&gt;You ask an LLM to analyse a log file and tell you the severity of what it found and a suggested fix. It comes back with three well-written paragraphs. Somewhere in there is the word &amp;ldquo;critical&amp;rdquo;, and somewhere is the fix.&lt;/p&gt;
&lt;p&gt;Your program now has to &lt;em&gt;extract&lt;/em&gt; those two facts from prose, and prose has no contract. The next run, the model phrases it differently. It leads with a caveat. It says &amp;ldquo;severe&amp;rdquo; where last time it said &amp;ldquo;critical&amp;rdquo;. It puts the fix first. Anything that worked by finding &amp;ldquo;critical&amp;rdquo; in the text is now quietly wrong, and you didn&amp;rsquo;t change a line. Parsing free text for structured facts is a game you lose slowly.&lt;/p&gt;
&lt;p&gt;What you actually wanted was never a paragraph. It was a value: a thing with a &lt;code&gt;severity&lt;/code&gt; field and a &lt;code&gt;fix&lt;/code&gt; field, that you can branch on and store and pass around like any other.&lt;/p&gt;
&lt;h2 id="ask-for-the-struct-not-the-prose"&gt;Ask for the struct, not the prose
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package draws the line with two methods. &lt;code&gt;Chat&lt;/code&gt; gives you text. &lt;code&gt;Ask&lt;/code&gt; gives you a struct.&lt;/p&gt;
&lt;p&gt;You define the Go type you want back:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Severity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;severity&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Fix&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;fix&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Analysis&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Analyse this log file: &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;logText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The framework generates a JSON Schema from that struct, sends it to the model as the required response format, and unmarshals the reply straight into &lt;code&gt;result&lt;/code&gt;. You never lay a finger on the prose. You get &lt;code&gt;result.Severity&lt;/code&gt; and &lt;code&gt;result.Fix&lt;/code&gt;, typed, ready to use. If you want the model&amp;rsquo;s answer to drive a &lt;code&gt;switch&lt;/code&gt; statement, this is the method that lets it.&lt;/p&gt;
&lt;h2 id="the-struct-is-the-schema-is-the-contract"&gt;The struct is the schema is the contract
&lt;/h2&gt;&lt;p&gt;The detail that makes this hold up over time: you don&amp;rsquo;t write the schema. The struct &lt;em&gt;is&lt;/em&gt; the schema.&lt;/p&gt;
&lt;p&gt;The framework derives the JSON Schema from your type. In go-tool-base that&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/schema.go#L11" target="_blank" rel="noopener"
 &gt;&lt;code&gt;GenerateSchema[T]()&lt;/code&gt;&lt;/a&gt;; in rust-tool-base the schema comes from your Rust type through &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/9c22aa8/crates/rtb-ai/src/client.rs#L208" target="_blank" rel="noopener"
 &gt;&lt;code&gt;schemars&lt;/code&gt;&lt;/a&gt;. (Yes, there&amp;rsquo;s a Rust sibling now. I&amp;rsquo;ll &lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;introduce it properly&lt;/a&gt; in a few weeks, but it keeps gatecrashing these posts because the two frameworks deliberately share ideas.) Either way there&amp;rsquo;s one definition, your type, and the schema is just a projection of it.&lt;/p&gt;
&lt;p&gt;That matters, because otherwise two things have to agree. There&amp;rsquo;s the schema you tell the model to obey, and there&amp;rsquo;s the type you unmarshal the answer into. Hand-write the schema and those two can drift: add a field to the struct, forget to add it to the schema, and the model is never told to produce it, so it silently never appears. Deriving the schema from the type collapses the two into one. They can&amp;rsquo;t disagree, because there&amp;rsquo;s only one of them.&lt;/p&gt;
&lt;h2 id="both-frameworks-with-one-extra-step-in-rust"&gt;Both frameworks, with one extra step in Rust
&lt;/h2&gt;&lt;p&gt;go-tool-base does this with &lt;code&gt;Ask&lt;/code&gt; and a &lt;code&gt;ResponseSchema&lt;/code&gt; set on the client config. rust-tool-base does it with &lt;code&gt;chat_structured::&amp;lt;T&amp;gt;&lt;/code&gt;, where &lt;code&gt;T&lt;/code&gt; is any type that&amp;rsquo;s both deserialisable and &lt;code&gt;JsonSchema&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;rust-tool-base adds one step worth calling out. Before it deserialises the model&amp;rsquo;s reply into your &lt;code&gt;T&lt;/code&gt;, it &lt;em&gt;validates&lt;/em&gt; the raw response against the schema with a JSON Schema validator. That splits the failure into two distinct, named cases: the response didn&amp;rsquo;t match the schema, or it matched the schema but still wouldn&amp;rsquo;t deserialise. A model that returns subtly wrong JSON fails loudly and specifically, with an error that tells you which of those happened, instead of quietly handing you a zero-valued struct that you end up debugging an hour later.&lt;/p&gt;
&lt;h2 id="when-youd-reach-for-it"&gt;When you&amp;rsquo;d reach for it
&lt;/h2&gt;&lt;p&gt;The line is simple, and it&amp;rsquo;s about who reads the answer.&lt;/p&gt;
&lt;p&gt;If a &lt;em&gt;human&lt;/em&gt; reads the answer, prose is right. &lt;code&gt;Chat&lt;/code&gt;, free text, let the model write well. A summary, an explanation, an interactive reply: leave all of those as prose.&lt;/p&gt;
&lt;p&gt;If a &lt;em&gt;program&lt;/em&gt; consumes the answer, you want a value. Classification, extraction, a code review scored out of a hundred with a list of issues, a yes-or-no with reasons: anything where the next thing that happens is your code branching on the result. There, &lt;code&gt;Ask&lt;/code&gt; and &lt;code&gt;chat_structured&lt;/code&gt; turn the LLM from something you have to interpret into something that returns a value, and a typed value is a thing you can actually build on.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;An LLM returns prose by default, and prose has no contract, so a program that picks structured facts out of it breaks the moment the model rephrases.&lt;/p&gt;
&lt;p&gt;Structured output asks for the value instead. You define a struct, the framework derives a JSON Schema from it, the model is constrained to that shape, and you get a typed result. go-tool-base&amp;rsquo;s &lt;code&gt;Ask&lt;/code&gt; and rust-tool-base&amp;rsquo;s &lt;code&gt;chat_structured&lt;/code&gt; both work this way, with the schema derived from your type so the schema and the type can&amp;rsquo;t drift; rust-tool-base additionally validates the response against the schema before deserialising. Use it whenever the answer feeds code rather than a human. It&amp;rsquo;s one of the four methods that make up &lt;a class="link" href="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/" &gt;go-tool-base&amp;rsquo;s small chat interface&lt;/a&gt;, and it&amp;rsquo;s the one that makes an LLM safe to program against.&lt;/p&gt;</description></item><item><title>Telemetry that asks first</title><link>https://phpboyscout.uk/telemetry-that-asks-first/</link><pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/telemetry-that-asks-first/</guid><description>&lt;img src="https://phpboyscout.uk/telemetry-that-asks-first/cover-telemetry-that-asks-first.png" alt="Featured image of post Telemetry that asks first" /&gt;&lt;p&gt;Usage telemetry is genuinely useful. Knowing which commands people actually run, where the errors cluster, whether anyone ever touched the feature you spent a fortnight on&amp;hellip; that&amp;rsquo;s the stuff that makes you a better maintainer. Wanting it is completely legitimate.&lt;/p&gt;
&lt;p&gt;The trouble is that the &lt;em&gt;usual&lt;/em&gt; way of getting it, on by default and quietly hoovering up everything, is a small betrayal of the people who installed your tool to get a job done. I wasn&amp;rsquo;t willing to build that, so go-tool-base&amp;rsquo;s telemetry starts from a different question.&lt;/p&gt;
&lt;h2 id="the-data-you-want-and-the-line-you-shouldnt-cross"&gt;The data you want, and the line you shouldn&amp;rsquo;t cross
&lt;/h2&gt;&lt;p&gt;If you maintain a tool, you want to know how it&amp;rsquo;s actually used. Which commands matter and which are dead weight. Where the error rate spikes. Whether anyone touched the feature you spent that fortnight on. That information makes you a better maintainer, and, to say it again, wanting it is completely legitimate.&lt;/p&gt;
&lt;p&gt;The trouble is the standard way of getting it. Telemetry on by default. An opt-out buried three levels down in a settings file nobody reads. And once it&amp;rsquo;s running, it quietly collects far more than it ever admitted to: the arguments people passed, the paths they were working in, an IP address for good measure.&lt;/p&gt;
&lt;p&gt;Every one of those is a small betrayal of someone who installed your tool to get a job done, not to become a data point. And the cost when users notice isn&amp;rsquo;t a slap on the wrist. It&amp;rsquo;s trust, and trust in a developer tool does not grow back quickly. A tool that surprises you once with what it was quietly collecting is a tool you uninstall and warn your colleagues about.&lt;/p&gt;
&lt;p&gt;So go-tool-base&amp;rsquo;s telemetry started from a different question. Not &amp;ldquo;how do we collect the most data&amp;rdquo; but &amp;ldquo;how do we collect &lt;em&gt;useful&lt;/em&gt; data without ever putting the user in a position they didn&amp;rsquo;t choose&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="rule-one-it-is-off-until-you-say-otherwise"&gt;Rule one: it is off until you say otherwise
&lt;/h2&gt;&lt;p&gt;The foundation is the simplest possible rule, and it&amp;rsquo;s absolute. Telemetry is &lt;strong&gt;never enabled by default.&lt;/strong&gt; A freshly installed tool built on go-tool-base sends nothing. Not a heartbeat, not a ping, nothing at all.&lt;/p&gt;
&lt;p&gt;It only starts collecting when the user makes an explicit, visible choice to let it. Three honest doors: they run &lt;code&gt;telemetry enable&lt;/code&gt;, they say yes to a clear prompt during &lt;code&gt;init&lt;/code&gt;, or they set &lt;code&gt;TELEMETRY_ENABLED&lt;/code&gt; themselves. All three are deliberate acts. None of them is a pre-ticked box or a default they have to discover and then undo.&lt;/p&gt;
&lt;p&gt;This is opt-&lt;em&gt;in&lt;/em&gt;, and the distinction from a well-hidden opt-&lt;em&gt;out&lt;/em&gt; is the entire point. Opt-out telemetry treats consent as something to be assumed and grudgingly reversed. Opt-in treats it as something that has to be &lt;em&gt;given&lt;/em&gt;. Only one of those is actually consent.&lt;/p&gt;
&lt;h2 id="rule-two-no-personally-identifiable-information-full-stop"&gt;Rule two: no personally identifiable information, full stop
&lt;/h2&gt;&lt;p&gt;Consent to &amp;ldquo;some telemetry&amp;rdquo; is not consent to &amp;ldquo;any telemetry&amp;rdquo;, so the second rule constrains what can ever be collected, even from a user who&amp;rsquo;s opted in.&lt;/p&gt;
&lt;p&gt;No personally identifiable information. The framework does not record command arguments (they routinely contain paths, hostnames, the occasional secret someone&amp;rsquo;s pasted in). It does not record file contents. It does not record IP addresses.&lt;/p&gt;
&lt;p&gt;It does need &lt;em&gt;some&lt;/em&gt; notion of &amp;ldquo;distinct installations&amp;rdquo; for the numbers to mean anything, so it derives a machine ID from a handful of system signals and runs it through &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/machine.go#L12" target="_blank" rel="noopener"
 &gt;SHA-256&lt;/a&gt;. What leaves the machine is a hash. It tells you &amp;ldquo;this is the same install as last week&amp;rdquo; and tells you precisely nothing about whose install it is, and the hash can&amp;rsquo;t be walked backwards into the signals it came from.&lt;/p&gt;
&lt;p&gt;The events themselves are deliberately thin. Which command ran, roughly how long it took, whether it errored. The shape of usage, not a transcript of it.&lt;/p&gt;
&lt;h2 id="rule-three-the-author-picks-the-destination"&gt;Rule three: the author picks the destination
&lt;/h2&gt;&lt;p&gt;Even with consent given and PII excluded, there&amp;rsquo;s a third question: where does the data actually &lt;em&gt;go&lt;/em&gt;? go-tool-base doesn&amp;rsquo;t answer that for you, because it can&amp;rsquo;t. A corporate internal tool, an open-source CLI and an air-gapped utility have completely different right answers.&lt;/p&gt;
&lt;p&gt;So the backend is the tool author&amp;rsquo;s choice. The framework ships several (a noop backend, stdout, a file, plain HTTP, and OpenTelemetry over OTLP) and supports custom ones. The noop backend matters more than it looks: it lets a tool wire up the whole telemetry surface, commands and all, while sending data precisely nowhere. A perfectly reasonable, fully supported configuration.&lt;/p&gt;
&lt;p&gt;Pluggable backends also mean the data never has to touch any infrastructure I run. It goes where the tool&amp;rsquo;s author decides, on their terms. The framework provides the plumbing and stays well out of the destination.&lt;/p&gt;
&lt;h2 id="and-a-way-back-out"&gt;And a way back out
&lt;/h2&gt;&lt;p&gt;One last thing, because it&amp;rsquo;s the part that makes the opt-in real rather than decorative. A user who opted in can opt straight back out, and the package includes a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/deletion.go#L24" target="_blank" rel="noopener"
 &gt;GDPR-aligned deletion path&lt;/a&gt;, so &amp;ldquo;stop, and remove what you have&amp;rdquo; is an actual supported request rather than a polite fiction.&lt;/p&gt;
&lt;p&gt;Consent you can&amp;rsquo;t withdraw isn&amp;rsquo;t consent. It&amp;rsquo;s a one-way door with a friendly sign on it. The deletion path is what keeps the front door an actual door.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Telemetry is genuinely useful to a maintainer and genuinely dangerous to the trust of the people running the tool, and the usual implementation (on by default, opt-out buried, collecting everything) spends that trust recklessly. go-tool-base&amp;rsquo;s telemetry holds three lines: never enabled without an explicit user action, never collecting personally identifiable information even once enabled, and always sending data to a destination the tool&amp;rsquo;s author chose, up to and including nowhere. A real deletion path makes the opt-in something you can take back.&lt;/p&gt;
&lt;p&gt;You can have your usage numbers. You just have to ask for them, the way you would for anything else that wasn&amp;rsquo;t yours to begin with.&lt;/p&gt;</description></item><item><title>Letting the AI call your Go functions</title><link>https://phpboyscout.uk/letting-the-ai-call-your-go-functions/</link><pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/letting-the-ai-call-your-go-functions/</guid><description>&lt;img src="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/cover-letting-the-ai-call-your-go-functions.png" alt="Featured image of post Letting the AI call your Go functions" /&gt;&lt;p&gt;An AI that can only produce text can &lt;em&gt;describe&lt;/em&gt; your system. An AI that can call your Go functions can actually operate it. That gap, between describing and doing, is the difference between a chatbot and something genuinely useful, and crossing it comes down to one fiddly mechanism: tool-calling, and the loop that drives it.&lt;/p&gt;
&lt;h2 id="talking-about-the-system-versus-operating-it"&gt;Talking about the system versus operating it
&lt;/h2&gt;&lt;p&gt;Wire an AI provider into a CLI command and you get something that can talk. Ask it a question, get a paragraph back. Useful, up to a point.&lt;/p&gt;
&lt;p&gt;But notice the ceiling. An AI that can only generate text can &lt;em&gt;describe&lt;/em&gt; things. It can tell you what it would do. What it can&amp;rsquo;t do is look at the actual current state of your system, or take a real action, because it has no hands. It&amp;rsquo;s reasoning in a vacuum about a world it can&amp;rsquo;t reach out and touch.&lt;/p&gt;
&lt;p&gt;The thing that gives it hands is tool-calling. You hand the AI a set of functions it&amp;rsquo;s allowed to call. Now, mid-conversation, it can decide it needs to &lt;em&gt;read that file&lt;/em&gt; before it can answer, or &lt;em&gt;run that query&lt;/em&gt;, or &lt;em&gt;check that status&lt;/em&gt;, and actually go and do it, and then reason about the real result. The AI stops describing your system and starts operating it.&lt;/p&gt;
&lt;h2 id="the-loop-is-the-hard-part"&gt;The loop is the hard part
&lt;/h2&gt;&lt;p&gt;Tool-calling has a shape, and the shape is a loop. The literature calls it ReAct: Reason, Act, Observe.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The AI &lt;strong&gt;reasons&lt;/strong&gt; about the prompt and decides whether it needs a tool.&lt;/li&gt;
&lt;li&gt;If it does, it &lt;strong&gt;acts&lt;/strong&gt;, asking for a specific tool with specific arguments.&lt;/li&gt;
&lt;li&gt;Your code runs the tool and feeds the result back. The AI &lt;strong&gt;observes&lt;/strong&gt; that result.&lt;/li&gt;
&lt;li&gt;Round again. Reason about the new information, maybe call another tool, maybe several. Keep going until the AI has what it needs and produces a final text answer with no more tool calls.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Conceptually simple. Tedious and error-prone to implement by hand every single time: parsing the model&amp;rsquo;s tool-call requests, dispatching to the right function, marshalling arguments in and results out, feeding observations back in the exact format the provider expects, knowing when to stop, and not looping forever if the model gets itself stuck.&lt;/p&gt;
&lt;p&gt;That orchestration is pure plumbing, and it&amp;rsquo;s identical for every tool and every command. So you can probably guess what&amp;rsquo;s coming: go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package owns it. You don&amp;rsquo;t write the loop. You write the tools.&lt;/p&gt;
&lt;h2 id="defining-a-tool"&gt;Defining a tool
&lt;/h2&gt;&lt;p&gt;A &lt;code&gt;chat.Tool&lt;/code&gt; is four things: a name, a description, a parameter schema, and a handler. The description is what the AI reads to decide &lt;em&gt;whether&lt;/em&gt; to use the tool, so it&amp;rsquo;s worth writing well. The schema describes the arguments, and you don&amp;rsquo;t hand-write it. You write a tagged Go struct and let it generate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ReadFileParams&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`json:&amp;#34;path&amp;#34; jsonschema_description:&amp;#34;Relative path to the file&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The struct is the contract. The framework derives the JSON Schema the AI is given straight from those tags, so the schema and the Go type the handler receives can&amp;rsquo;t drift apart, because they share a single source. The handler is then just an ordinary Go function that takes those parameters and returns a result.&lt;/p&gt;
&lt;p&gt;You register your tools with &lt;code&gt;SetTools&lt;/code&gt;, call &lt;code&gt;Chat&lt;/code&gt;, and that&amp;rsquo;s the whole of your involvement. The framework runs the ReAct loop and &lt;code&gt;Chat&lt;/code&gt; returns the AI&amp;rsquo;s final text answer once the loop settles.&lt;/p&gt;
&lt;h2 id="two-details-that-show-it-was-built-for-real-use"&gt;Two details that show it was built for real use
&lt;/h2&gt;&lt;p&gt;A couple of decisions in the loop tell you it&amp;rsquo;s meant for production, not a demo.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tool errors don&amp;rsquo;t abort the conversation.&lt;/strong&gt; When a handler returns an error, the framework doesn&amp;rsquo;t crash the loop. It hands the error &lt;em&gt;back to the AI as a string&lt;/em&gt;, as just another observation. That&amp;rsquo;s deliberate, and it&amp;rsquo;s right. A real agent should be able to call a tool, watch it fail, and react: try different arguments, take a different route, or tell the user it couldn&amp;rsquo;t manage it. A loop that aborted on the first tool error would be far more brittle than the model driving it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The loop is bounded.&lt;/strong&gt; There&amp;rsquo;s a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/constants.go#L16" target="_blank" rel="noopener"
 &gt;&lt;code&gt;MaxSteps&lt;/code&gt; limit, default 20&lt;/a&gt;. An AI that gets confused could otherwise call tools forever, and a CLI command that never returns is a worse failure than a wrong answer. The cap guarantees the command terminates. The agent gets room to genuinely work a problem across many steps, but not infinite room to flail about in.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also parallel tool execution: when the model asks for several tools in a single step (three independent file reads, say) the framework runs them concurrently rather than one after another, because there&amp;rsquo;s no reason to make the AI sit and wait out a sequence of things that don&amp;rsquo;t depend on each other.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;A text-only AI can describe your system; an AI that can call your functions can operate it. Bridging that gap means tool-calling, and tool-calling means the ReAct loop (reason, act, observe, repeat) whose orchestration is fiddly, identical every time, and not a problem worth solving twice.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package runs the loop for you. You define &lt;code&gt;chat.Tool&lt;/code&gt; values (name, description, a tagged parameter struct that generates its own schema, a handler), call &lt;code&gt;SetTools&lt;/code&gt; and &lt;code&gt;Chat&lt;/code&gt;, and get the final answer. Tool errors go back to the AI as observations so it can recover, and a &lt;code&gt;MaxSteps&lt;/code&gt; cap guarantees the command always terminates. You write Go functions. The framework turns them into things an agent can reach for.&lt;/p&gt;</description></item><item><title>Nobody reads the manual</title><link>https://phpboyscout.uk/nobody-reads-the-manual/</link><pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/nobody-reads-the-manual/</guid><description>&lt;img src="https://phpboyscout.uk/nobody-reads-the-manual/cover-nobody-reads-the-manual.png" alt="Featured image of post Nobody reads the manual" /&gt;&lt;p&gt;Let me describe the actual lifecycle of a user meeting your CLI tool, because it&amp;rsquo;s a bit humbling. They run it. It doesn&amp;rsquo;t quite do what they expected. They run it again with &lt;code&gt;--help&lt;/code&gt;. They get a wall of monospaced flag descriptions, skim it, don&amp;rsquo;t find the thing they wanted, and either give up or go and ask a human who already knows.&lt;/p&gt;
&lt;p&gt;Your documentation might be magnificent. It doesn&amp;rsquo;t matter, because the user never reached it.&lt;/p&gt;
&lt;h2 id="the-manual-loses-on-location-not-quality"&gt;The manual loses on location, not quality
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the lifecycle, and notice exactly where it breaks. The documentation might be excellent. It might answer their precise question in full. It doesn&amp;rsquo;t matter, because it&amp;rsquo;s on a website, in another window, behind a search box, and the user is &lt;em&gt;here&lt;/em&gt;, in the terminal, mid-task. The docs lost not on quality but on &lt;em&gt;location&lt;/em&gt;. They simply weren&amp;rsquo;t where the work was.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s answer starts with a decision about location: the documentation gets embedded into the binary itself. Your &lt;code&gt;docs/&lt;/code&gt; folder ships &lt;em&gt;inside&lt;/em&gt; the tool, the same way its default config does. Wherever the tool is installed, the docs are right there alongside it, no network, no browser. That embedding is what makes everything else possible, and there are two things built on top of it.&lt;/p&gt;
&lt;h2 id="a-browser-in-the-terminal"&gt;A browser, in the terminal
&lt;/h2&gt;&lt;p&gt;The first is the &lt;code&gt;docs&lt;/code&gt; command, and it&amp;rsquo;s not &lt;code&gt;--help&lt;/code&gt; with extra steps. It launches a proper Terminal User Interface, built on Bubble Tea.&lt;/p&gt;
&lt;p&gt;It has a sidebar, structured from the project&amp;rsquo;s own &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/docs/docs.go#L25" target="_blank" rel="noopener"
 &gt;&lt;code&gt;mkdocs.yml&lt;/code&gt;&lt;/a&gt;, so the docs are a navigable tree rather than one flat scroll. Markdown renders with real formatting through Glamour (colour, tables, lists, headings) instead of collapsing into monospaced soup. There&amp;rsquo;s live search across every page, regex included.&lt;/p&gt;
&lt;p&gt;Compared with &lt;code&gt;man&lt;/code&gt; and &lt;code&gt;--help&lt;/code&gt;, the difference isn&amp;rsquo;t a nicer coat of paint. &lt;code&gt;man&lt;/code&gt; gives you linear scrolling and grep; this gives you a structured tree, rich rendering and real search. It&amp;rsquo;s the documentation experience a modern developer expects, except it followed the tool &lt;em&gt;into&lt;/em&gt; the terminal instead of demanding the user leave it.&lt;/p&gt;
&lt;h2 id="a-documentation-assistant-that-wont-make-things-up"&gt;A documentation assistant that won&amp;rsquo;t make things up
&lt;/h2&gt;&lt;p&gt;The second thing built on the embedded docs is the one I find genuinely transformative: &lt;code&gt;docs ask&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The user doesn&amp;rsquo;t navigate anything. They just ask:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mytool docs ask &lt;span class="s2"&gt;&amp;#34;how do I point this at a self-hosted server?&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and get a direct, specific answer. Under the hood, the framework collates the tool&amp;rsquo;s embedded markdown and hands it to the configured AI provider (Claude, OpenAI, Gemini, Claude Local, any OpenAI-compatible endpoint) as the context for the question.&lt;/p&gt;
&lt;p&gt;Now, &amp;ldquo;an AI answers questions about my tool&amp;rdquo; should immediately make you nervous, and the correct thing to be nervous about is hallucination. An AI that confidently invents a flag that doesn&amp;rsquo;t exist, or describes behaviour the tool simply doesn&amp;rsquo;t have, is worse than no assistant at all, because the user &lt;em&gt;trusts&lt;/em&gt; it.&lt;/p&gt;
&lt;p&gt;This is where embedding the docs pays off a second time, and it&amp;rsquo;s why I keep stressing that the corpus is &lt;em&gt;closed&lt;/em&gt;. The model is instructed to answer &lt;strong&gt;only&lt;/strong&gt; from the tool&amp;rsquo;s actual documentation, and the context it&amp;rsquo;s handed is exactly that documentation and nothing else. It isn&amp;rsquo;t drawing on a vague memory of similar tools from its training data. It&amp;rsquo;s answering from this tool&amp;rsquo;s real, shipped, version-matched docs. The corpus is small, closed and authoritative, which is the combination that keeps the answers honest. &amp;ldquo;Zero hallucination by design&amp;rdquo; isn&amp;rsquo;t a slogan about the model. It&amp;rsquo;s a property of bounding what the model is allowed to look at, which is the same instinct I &lt;a class="link" href="https://phpboyscout.uk/your-cli-is-already-an-ai-tool/" &gt;leaned on with the &lt;code&gt;mcp&lt;/code&gt; command&lt;/a&gt;: the safety comes from the boundary you drew, not from trusting the AI to behave itself.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a nice second-order effect, too. The answer is always about the version of the tool the user actually has, because the docs were embedded into &lt;em&gt;that build&lt;/em&gt;. No mismatch between a website documenting the latest release and the slightly older binary sitting on the user&amp;rsquo;s machine.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;Documentation usually loses to &lt;code&gt;--help&lt;/code&gt; not on quality but on location: it&amp;rsquo;s in a browser, and the user is in the terminal. go-tool-base embeds the docs into the binary and surfaces them two ways: a &lt;code&gt;docs&lt;/code&gt; command that&amp;rsquo;s a real TUI browser with a sidebar, rich markdown and search, and &lt;code&gt;docs ask&lt;/code&gt;, which answers natural-language questions using the embedded docs as context.&lt;/p&gt;
&lt;p&gt;Because that context is the tool&amp;rsquo;s own closed, shipped documentation and the model is told to use nothing else, the assistant stays grounded, and it&amp;rsquo;s always describing the exact version the user is holding. The fix for unread documentation was never to write more of it. It was to put it where the work happens and let it answer back.&lt;/p&gt;</description></item><item><title>BDD where it earns its place, and nowhere else</title><link>https://phpboyscout.uk/bdd-where-it-earns-its-place/</link><pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/bdd-where-it-earns-its-place/</guid><description>&lt;img src="https://phpboyscout.uk/bdd-where-it-earns-its-place/cover-bdd-where-it-earns-its-place.png" alt="Featured image of post BDD where it earns its place, and nowhere else" /&gt;&lt;p&gt;I have a slightly complicated relationship with BDD. I&amp;rsquo;ve watched it turn a tangled test suite into something the whole team could read and reason about, and I&amp;rsquo;ve watched it turn a perfectly good unit test into a paragraph of ceremonial English that nobody benefits from. So when go-tool-base brought in Cucumber-style BDD, the interesting decision wasn&amp;rsquo;t adopting it. It was being ruthless about where &lt;em&gt;not&lt;/em&gt; to.&lt;/p&gt;
&lt;h2 id="two-tests-that-hurt-for-different-reasons"&gt;Two tests that hurt for different reasons
&lt;/h2&gt;&lt;p&gt;Most of go-tool-base&amp;rsquo;s tests are ordinary table-driven Go tests, and they&amp;rsquo;re absolutely fine. A function, a slice of input/expected pairs, a loop. Nobody needs Gherkin to understand a parser test.&lt;/p&gt;
&lt;p&gt;But two areas were genuinely painful, and they were painful in the same way: the &lt;em&gt;test&lt;/em&gt; had become harder to understand than the &lt;em&gt;thing it was testing&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The first was &lt;code&gt;pkg/controls&lt;/code&gt;, the &lt;a class="link" href="https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/" &gt;service-lifecycle package&lt;/a&gt;. It runs a small state machine (Unknown, Running, Stopping, Stopped) with signal handling, health monitoring, restart policies and graceful shutdown all woven through it. The integration tests for graceful shutdown had grown to over three hundred lines of imperative goroutine and channel coordination. They worked. But reviewing them was a slog, and a test you can&amp;rsquo;t review with confidence is a test you can&amp;rsquo;t trust when it fails. The behaviour being checked, &amp;ldquo;when a shutdown signal arrives mid-startup, the controller stops cleanly&amp;rdquo;, was a simple sentence buried under a heap of synchronisation scaffolding.&lt;/p&gt;
&lt;p&gt;The second was the CLI itself. &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt; are user &lt;em&gt;workflows&lt;/em&gt;. &amp;ldquo;Given a config file with a custom value, when I run init, then the custom value survives the merge.&amp;rdquo; That&amp;rsquo;s already a Given/When/Then; it just happened to be written out as Go.&lt;/p&gt;
&lt;h2 id="godog-and-the-line-i-drew"&gt;Godog, and the line I drew
&lt;/h2&gt;&lt;p&gt;Godog is the official Go implementation of Cucumber. You write &lt;code&gt;.feature&lt;/code&gt; files in plain Gherkin and bind each step to a Go function. The shutdown scenario stops being three hundred lines of channels and becomes this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gherkin" data-lang="gherkin"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;Scenario:&lt;/span&gt;&lt;span class="nf"&gt; graceful shutdown completes within the deadline
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt; Given &lt;/span&gt;&lt;span class="nf"&gt;a controller with two registered services
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nf"&gt; &lt;/span&gt;&lt;span class="k"&gt;When &lt;/span&gt;&lt;span class="nf"&gt;a shutdown signal is received
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nf"&gt; &lt;/span&gt;&lt;span class="k"&gt;Then &lt;/span&gt;&lt;span class="nf"&gt;both services stop in registration order
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nf"&gt; &lt;/span&gt;&lt;span class="k"&gt;And &lt;/span&gt;&lt;span class="nf"&gt;the controller reports a clean shutdown
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goroutine choreography doesn&amp;rsquo;t vanish, of course. It moves into the step definitions, written once and reused. What changes is that the &lt;em&gt;scenario&lt;/em&gt; is now readable by someone who&amp;rsquo;s never opened the file before, including someone from an ops team who&amp;rsquo;ll never write a line of Go but absolutely has opinions about how shutdown should behave.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part I want to dwell on, because it&amp;rsquo;s the part most BDD adoptions get wrong. The first design decision written down for this work was: &lt;strong&gt;strategic, not universal.&lt;/strong&gt; Use Godog &lt;em&gt;only&lt;/em&gt; where BDD adds clarity. Keep table-driven Go tests as the baseline everywhere else.&lt;/p&gt;
&lt;p&gt;That sounds obvious written down. It is not obvious in practice, because BDD has a gravitational pull. Once a team has feature files, there&amp;rsquo;s a powerful urge to express &lt;em&gt;everything&lt;/em&gt; as feature files, for consistency. And that&amp;rsquo;s how you end up with Gherkin scenarios for a pure function (&lt;code&gt;Given the number 2, When I double it, Then I get 4&lt;/code&gt;) which is pure ceremony. You&amp;rsquo;ve wrapped a one-line table test in a paragraph of English and a step-definition indirection, and made it actively worse.&lt;/p&gt;
&lt;p&gt;The test for whether BDD belongs is this: &lt;strong&gt;is this test a narrative, or is it a matrix?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A matrix is the same logic with many input/output pairs. That&amp;rsquo;s a table-driven test, that&amp;rsquo;s most unit tests, and Gherkin actively harms them. A narrative is a sequence of steps where the &lt;em&gt;ordering&lt;/em&gt; and the &lt;em&gt;state between steps&lt;/em&gt; is the thing under test, and that&amp;rsquo;s where Gherkin pays for itself. Lifecycle transitions are narratives. A user running three commands in sequence is a narrative. Doubling a number is not.&lt;/p&gt;
&lt;p&gt;go-tool-base drew that line and stuck to it. Feature files live in &lt;code&gt;features/&lt;/code&gt; at the project root, where a non-Go developer can find and read them. Step definitions live in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/test/e2e/steps/controls_steps_test.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;test/e2e/&lt;/code&gt;&lt;/a&gt;, kept well away from the unit tests. And the unit tests stayed exactly what they were, because they were already the right tool.&lt;/p&gt;
&lt;h2 id="made-to-fit-not-bolted-on"&gt;Made to fit, not bolted on
&lt;/h2&gt;&lt;p&gt;A couple of smaller decisions kept the BDD layer from feeling like a foreign object.&lt;/p&gt;
&lt;p&gt;It runs under &lt;code&gt;go test&lt;/code&gt;. There&amp;rsquo;s no separate Cucumber runner to install or remember. A &lt;code&gt;godog.TestSuite&lt;/code&gt; is invoked from an ordinary &lt;code&gt;TestFeatures(t *testing.T)&lt;/code&gt;, so the BDD scenarios run in the same &lt;code&gt;go test ./...&lt;/code&gt; as everything else. CI didn&amp;rsquo;t need a new concept bolted onto it.&lt;/p&gt;
&lt;p&gt;And the CLI end-to-end tests build the &lt;code&gt;gtb&lt;/code&gt; binary &lt;em&gt;once&lt;/em&gt; and reuse it across every scenario. Compiling a binary per scenario would make the suite slow enough that people would quietly start skipping it, and a test suite people skip is just decoration. Build once, test many.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;go-tool-base brought in Godog for BDD, but the decision worth writing about is the restraint. BDD was applied to exactly two things: the service-lifecycle state machine, where a 300-line goroutine tangle became a four-line scenario anyone can review, and CLI workflows, which are Given/When/Then by their very nature. Everywhere else, table-driven Go tests remained the baseline, because wrapping a matrix test in Gherkin makes it worse, not better.&lt;/p&gt;
&lt;p&gt;The useful rule: BDD fits a &lt;em&gt;narrative&lt;/em&gt;, ordered steps with meaningful state in between, and fights a &lt;em&gt;matrix&lt;/em&gt;. Adopt it as a scalpel for the narratives. Resist the pull to turn it into a religion.&lt;/p&gt;</description></item><item><title>An AI interface that fits on one screen</title><link>https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/</guid><description>&lt;img src="https://phpboyscout.uk/an-ai-interface-that-fits-on-one-screen/cover-an-ai-interface-that-fits-on-one-screen.png" alt="Featured image of post An AI interface that fits on one screen" /&gt;&lt;p&gt;The moment you decide a CLI tool should talk to an LLM, there&amp;rsquo;s a strong gravitational pull towards reaching for LangChain, or one of its many relatives. It&amp;rsquo;s the obvious move. It&amp;rsquo;s also, for most CLI work, a bit like hiring a removals firm to carry a single box up the stairs.&lt;/p&gt;
&lt;p&gt;Let me explain why go-tool-base went the other way, and what &amp;ldquo;the other way&amp;rdquo; actually looks like.&lt;/p&gt;
&lt;h2 id="the-instinct-and-why-it-overshoots"&gt;The instinct, and why it overshoots
&lt;/h2&gt;&lt;p&gt;When you add AI to a tool, the instinct is to reach for the big general-purpose framework. LangChain and its relatives are capable, and they exist for a real need: orchestrating complex multi-step AI applications, with retrieval pipelines, memory stores, chains of calls, whole fleets of agents.&lt;/p&gt;
&lt;p&gt;Now look at what a CLI tool actually needs from an LLM. It needs to send a prompt and get text back. Sometimes it wants structured data back instead of prose. Sometimes it wants to let the model call a few of the tool&amp;rsquo;s own functions. That&amp;rsquo;s pretty much the whole list.&lt;/p&gt;
&lt;p&gt;Pulling in a framework built to orchestrate retrieval and agent swarms in order to do &lt;em&gt;that&lt;/em&gt; is a poor trade. You take on a large new vocabulary of concepts, a wide dependency surface, and a great deal of abstraction you&amp;rsquo;ll never touch, all to perform three or four operations. The framework isn&amp;rsquo;t wrong. It&amp;rsquo;s just answering a far bigger question than the one a CLI tool is asking.&lt;/p&gt;
&lt;h2 id="what-go-tool-base-chose-instead"&gt;What go-tool-base chose instead
&lt;/h2&gt;&lt;p&gt;go-tool-base didn&amp;rsquo;t reach for a framework. The decision is on the record in its own design notes: before a single line was written, LangChain Go, go-openai, Vercel&amp;rsquo;s AI SDK and around ten other options were evaluated, and not one of them matched what a CLI framework actually needs. So the &lt;code&gt;chat&lt;/code&gt; package was built deliberately small.&lt;/p&gt;
&lt;p&gt;How small? The entire core &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/client.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ChatClient&lt;/code&gt;&lt;/a&gt; interface is four methods:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ChatClient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;SetTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="nx"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Add&lt;/code&gt; appends a message to the conversation. &lt;code&gt;Chat&lt;/code&gt; sends a prompt and returns text. &lt;code&gt;Ask&lt;/code&gt; sends a prompt and returns a &lt;em&gt;typed Go struct&lt;/em&gt;, the model&amp;rsquo;s answer unmarshalled straight into a value you defined. &lt;code&gt;SetTools&lt;/code&gt; hands the model a set of your own functions it&amp;rsquo;s allowed to call. That&amp;rsquo;s the whole surface. Downstream code that uses AI never holds anything larger than this, and never has to know which provider is behind it.&lt;/p&gt;
&lt;p&gt;The package&amp;rsquo;s own documentation has a word for this: right-sized. Large enough to solve genuine provider-abstraction complexity, small enough that the full interface fits on a single screen.&lt;/p&gt;
&lt;h2 id="thin-is-not-the-same-as-does-little"&gt;&amp;ldquo;Thin&amp;rdquo; is not the same as &amp;ldquo;does little&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;This is the part worth being precise about, because &amp;ldquo;four methods&amp;rdquo; can sound like &amp;ldquo;barely does anything&amp;rdquo;, and that&amp;rsquo;s the wrong read entirely.&lt;/p&gt;
&lt;p&gt;Behind those four methods sits genuinely awkward work. Five providers (OpenAI, Claude, Gemini, a locally installed &lt;code&gt;claude&lt;/code&gt; binary, and any OpenAI-compatible endpoint) each with a different wire API, all normalised behind the one interface. A &lt;a class="link" href="https://phpboyscout.uk/letting-the-ai-call-your-go-functions/" &gt;tool-calling loop&lt;/a&gt;. Structured output via JSON Schema, made to behave consistently across providers that each express it differently. Error normalisation. Token chunking.&lt;/p&gt;
&lt;p&gt;The point of a thin abstraction is not that there&amp;rsquo;s little underneath it. It&amp;rsquo;s that the &lt;em&gt;interface&lt;/em&gt; stays small while the &lt;em&gt;implementation&lt;/em&gt; quietly absorbs the complexity. Four methods on the surface; five provider integrations and a tool-calling loop below the waterline. The thinness is a property of what the caller sees, not of what the package does. A reach-for-LangChain decision gets that backwards: it exposes the caller to all the machinery, whether or not the caller will ever need it.&lt;/p&gt;
&lt;h2 id="the-core-stays-small-even-as-features-grow"&gt;The core stays small even as features grow
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a neat detail in how &lt;code&gt;chat&lt;/code&gt; keeps the interface from creeping. The package also supports streaming responses and conversation persistence, both of which are real features with real surface area. Neither of them is in the four-method core.&lt;/p&gt;
&lt;p&gt;Instead they&amp;rsquo;re &lt;em&gt;separate, optional&lt;/em&gt; interfaces. A streaming-capable client also satisfies &lt;code&gt;StreamingChatClient&lt;/code&gt;; a persistable one also satisfies &lt;code&gt;PersistentChatClient&lt;/code&gt;. Code that wants those capabilities does a type assertion to ask for them, and code that doesn&amp;rsquo;t simply never sees them. So the common path stays four methods forever. New capabilities arrive as opt-in interfaces alongside the core, not as new methods bolted onto it. The thing that fits on one screen keeps fitting on one screen.&lt;/p&gt;
&lt;h2 id="extensible-without-forking-testable-without-a-network"&gt;Extensible without forking, testable without a network
&lt;/h2&gt;&lt;p&gt;Two more properties keep the package small without making it limiting.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s extensible. The provider list isn&amp;rsquo;t closed. A &lt;code&gt;RegisterProvider&lt;/code&gt; call lets any package contribute a new provider, and &lt;code&gt;chat.New&lt;/code&gt; will route to it. You add a backend without forking &lt;code&gt;pkg/chat&lt;/code&gt; or sending a patch upstream.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s testable. The package ships generated mocks. A downstream tool&amp;rsquo;s AI features can be tested against a mock &lt;code&gt;ChatClient&lt;/code&gt; returning canned responses, with no network, no API key, and no flakiness. Because the interface is four methods, that mock is trivial to set up and complete by construction. A sprawling framework interface is a sprawling thing to fake; a four-method one is not. (I&amp;rsquo;ll come back to testing AI code properly &lt;a class="link" href="https://phpboyscout.uk/testing-code-that-calls-an-llm/" &gt;in a later post&lt;/a&gt;, because it deserves a whole article of its own.)&lt;/p&gt;
&lt;h2 id="the-right-size"&gt;The right size
&lt;/h2&gt;&lt;p&gt;When a CLI tool needs AI, the instinct is a large framework like LangChain. For orchestrating retrieval pipelines and agent swarms, that&amp;rsquo;s exactly the right tool. For sending a prompt, getting a struct back, and letting the model call a few functions, it&amp;rsquo;s enormous overkill.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s &lt;code&gt;chat&lt;/code&gt; package is the deliberate alternative, chosen only after LangChain Go and a dozen others were weighed up and rejected. Its core &lt;code&gt;ChatClient&lt;/code&gt; interface is four methods. Underneath sit five normalised providers, a tool-calling loop, structured output and error handling, but the caller sees four methods and never learns which provider is active. Streaming and persistence are opt-in interfaces beside the core, not additions to it. It extends without forking and tests without a network. Right-sized: the complexity is real, but it lives under the interface rather than in it.&lt;/p&gt;</description></item><item><title>The config key that quietly did nothing</title><link>https://phpboyscout.uk/the-config-key-that-quietly-did-nothing/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-config-key-that-quietly-did-nothing/</guid><description>&lt;img src="https://phpboyscout.uk/the-config-key-that-quietly-did-nothing/cover-the-config-key-that-quietly-did-nothing.png" alt="Featured image of post The config key that quietly did nothing" /&gt;&lt;p&gt;I once spent the better part of an hour convinced a timeout setting was broken. I&amp;rsquo;d set it in the config file, the tool ignored it, and the code that read it looked perfectly correct. The setting was &lt;code&gt;tiemout&lt;/code&gt;. I&amp;rsquo;d typed it wrong, and not one thing in the entire stack had thought that worth mentioning.&lt;/p&gt;
&lt;h2 id="config-loaders-are-too-polite"&gt;Config loaders are too polite
&lt;/h2&gt;&lt;p&gt;Most config loaders have the same agreeable flaw: they&amp;rsquo;ll read whatever&amp;rsquo;s in the file and quietly ignore anything they weren&amp;rsquo;t expecting. Put a key the tool doesn&amp;rsquo;t know about and it sails straight past. No error, no warning, nothing. The loader assumes you meant it, or assumes some other layer will care, and neither turns out to be true.&lt;/p&gt;
&lt;p&gt;That politeness costs you in two directions. A key you misspelled is silently dropped, so the setting you thought you&amp;rsquo;d changed keeps running on its old value. And a key you &lt;em&gt;forgot&lt;/em&gt; leaves the field at its zero value, which you then discover at runtime, usually at the least convenient moment, when something downstream divides by a timeout of zero. The file looked fine. It parsed fine. It was just quietly wrong, and nothing was watching for that.&lt;/p&gt;
&lt;h2 id="the-struct-already-knows-the-answer"&gt;The struct already knows the answer
&lt;/h2&gt;&lt;p&gt;The thing is, the program already has a complete description of what valid config looks like. It&amp;rsquo;s the struct you unmarshal into. The field names, the types, which ones matter. That description exists; it&amp;rsquo;s just not being used to &lt;em&gt;check&lt;/em&gt; anything.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s config package puts it to work. You hand it a tagged struct and it derives a schema from the tags, in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/config/schema.go#L48" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/config/schema.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// WithStructSchema derives a schema from a tagged Go struct.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Supported tags: `config:&amp;#34;key&amp;#34; validate:&amp;#34;required&amp;#34; enum:&amp;#34;a,b,c&amp;#34; default:&amp;#34;value&amp;#34;`.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;WithStructSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SchemaOption&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So a feature&amp;rsquo;s config type carries its own rules inline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ServerConfig&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`config:&amp;#34;host&amp;#34; validate:&amp;#34;required&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`config:&amp;#34;port&amp;#34; validate:&amp;#34;required&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;LogMode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;`config:&amp;#34;log_mode&amp;#34; enum:&amp;#34;text,json&amp;#34;`&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There&amp;rsquo;s no second artefact to keep in sync, which is the same instinct go-tool-base leans on for &lt;a class="link" href="https://phpboyscout.uk/stop-regexing-the-llms-prose/" &gt;structured AI output&lt;/a&gt;: the type is the schema, and the schema is a projection of the type, so the two can&amp;rsquo;t drift apart because there&amp;rsquo;s only one of them. Each package describes its own slice of config on its own struct, and &lt;code&gt;NewSchema&lt;/code&gt; composes them into the schema the loaded config gets checked against.&lt;/p&gt;
&lt;h2 id="strict-mode-turns-the-typo-into-an-error"&gt;Strict mode turns the typo into an error
&lt;/h2&gt;&lt;p&gt;Deriving the schema is half of it. The half that actually catches &lt;code&gt;tiemout&lt;/code&gt; is this one, also from &lt;code&gt;schema.go&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// WithStrictMode treats unknown keys as errors instead of warnings.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;WithStrictMode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;SchemaOption&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By default a key the schema doesn&amp;rsquo;t recognise is a &lt;em&gt;warning&lt;/em&gt;: surfaced, but not fatal, which is the right call when a config file might legitimately carry extra keys for tools other than yours. Turn on strict mode and an unknown key becomes an &lt;em&gt;error&lt;/em&gt;. &lt;code&gt;tiemout&lt;/code&gt; isn&amp;rsquo;t in the schema, so the tool refuses to start and tells me which key it didn&amp;rsquo;t recognise, instead of shrugging and using the default for an hour while I lose my mind. The validator walks every key actually present in the file and checks it against the known set, so a typo has nowhere to hide.&lt;/p&gt;
&lt;h2 id="what-it-deliberately-doesnt-do"&gt;What it deliberately doesn&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one decision in here I think is worth calling out, because the obvious feature is conspicuously absent. The schema knows each field&amp;rsquo;s default value. It would be the easiest thing in the world to have validation &lt;em&gt;fill in&lt;/em&gt; missing fields from those defaults.&lt;/p&gt;
&lt;p&gt;It doesn&amp;rsquo;t, on purpose. Validation validates. It tells you what&amp;rsquo;s wrong and what to do about it, and it stops there. Defaults are a separate job, handled by the &lt;a class="link" href="https://phpboyscout.uk/many-embedded-filesystems-one-merged-view/" &gt;embedded default config that every feature ships&lt;/a&gt; and merges in before validation ever runs. Keeping the two apart means the validator has exactly one responsibility, and the defaults live in one place rather than being half in an embedded file and half injected by a check. A field&amp;rsquo;s &lt;code&gt;default&lt;/code&gt; tag is there for the documentation and the error hint, not as a sneaky second source of values.&lt;/p&gt;
&lt;h2 id="errors-you-can-act-on"&gt;Errors you can act on
&lt;/h2&gt;&lt;p&gt;The output isn&amp;rsquo;t a bare boolean. Validation returns a result that separates the fatal from the advisory: the missing required field and the wrong type are errors that stop the tool; the unrecognised-but-harmless key is a warning that informs you without blocking. And because each problem carries the offending key by name and a hint about the fix, the message tells you what to change, in the spirit of &lt;a class="link" href="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/" &gt;errors that tell you what to do next&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;A config loader that silently ignores keys it doesn&amp;rsquo;t recognise will, sooner or later, ignore one you meant. go-tool-base derives a validation schema straight from your tagged config struct, so there&amp;rsquo;s no separate schema to maintain, and strict mode promotes an unknown key from a quiet shrug to a real error that names the typo. It validates without injecting defaults, because defaults are the embedded config&amp;rsquo;s job and a validator with one responsibility is easier to trust. Set &lt;code&gt;tiemout&lt;/code&gt; now and the tool tells you, which is roughly fifty-nine minutes sooner than I found out.&lt;/p&gt;</description></item><item><title>One variadic, and I'd already spent it</title><link>https://phpboyscout.uk/one-variadic-and-id-already-spent-it/</link><pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-variadic-and-id-already-spent-it/</guid><description>&lt;img src="https://phpboyscout.uk/one-variadic-and-id-already-spent-it/cover-one-variadic-and-id-already-spent-it.png" alt="Featured image of post One variadic, and I'd already spent it" /&gt;&lt;p&gt;I had a constructor I was rather pleased with. Hand &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;go-tool-base&lt;/a&gt;&amp;rsquo;s root command its props and as many sub-commands as you like, and off it goes. Then I needed to thread some config file paths through it, reached for the obvious &amp;ldquo;just add a parameter,&amp;rdquo; and discovered I&amp;rsquo;d already spent my one variadic with no second one going spare.&lt;/p&gt;
&lt;h2 id="the-ergonomics-id-happily-bought"&gt;The ergonomics I&amp;rsquo;d happily bought
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;NewCmdRoot&lt;/code&gt; ends in &lt;code&gt;subcommands ...*cobra.Command&lt;/code&gt;. That trailing &lt;code&gt;...&lt;/code&gt; is a small luxury: callers write &lt;code&gt;NewCmdRoot(props, build, deploy, status)&lt;/code&gt; and never have to think about slices. Variadics are lovely for exactly this, the &amp;ldquo;and as many of these as you fancy&amp;rdquo; argument.&lt;/p&gt;
&lt;h2 id="the-parameter-i-couldnt-add"&gt;The parameter I couldn&amp;rsquo;t add
&lt;/h2&gt;&lt;p&gt;Then config arrived, and the root command needed to know about some extra configuration file paths. The instinct is to add a parameter. The instinct is wrong, because there&amp;rsquo;s nowhere legal to put it.&lt;/p&gt;
&lt;p&gt;You can&amp;rsquo;t write &lt;code&gt;NewCmdRoot(props, configPaths ...string, subcommands ...*cobra.Command)&lt;/code&gt;. Go allows a function exactly one variadic, and it must be the final parameter. Two variadics results in a compile error before you&amp;rsquo;ve finished the line (assuming your IDE does compile time checks for you), and fairly so: at the call site, how would Go ever know where the strings stopped and the commands began? So the variadic I&amp;rsquo;d spent on sub-commands was spent. There wasn&amp;rsquo;t another to hand.&lt;/p&gt;
&lt;h2 id="the-choices-and-the-one-i-took"&gt;The choices, and the one I took
&lt;/h2&gt;&lt;p&gt;You can demote the variadic. Make it &lt;code&gt;subcommands []*cobra.Command&lt;/code&gt; and you&amp;rsquo;re free to add &lt;code&gt;configPaths []string&lt;/code&gt; next to it. Correct, and it breaks every existing call: &lt;code&gt;NewCmdRoot(props, build, deploy)&lt;/code&gt; becomes &lt;code&gt;NewCmdRoot(props, []string{}, []*cobra.Command{build, deploy})&lt;/code&gt;. Uglier at every site, to solve a problem only some callers have.&lt;/p&gt;
&lt;p&gt;You can reach for functional options, and for plenty of go-tool-base&amp;rsquo;s constructors that is exactly what happened. But the root builder is the one everybody calls first, with the simplest signature in the codebase, and I didn&amp;rsquo;t want the common case lugging option machinery around for the sake of the rare one.&lt;/p&gt;
&lt;p&gt;What I actually did was add a second door. From &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/cmd/root/root.go#L334-342" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/cmd/root/root.go&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// NewCmdRoot creates the root command with Props wiring and optional subcommands.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;subcommands&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdRootWithConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;subcommands&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdRootWithConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;configPaths&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;subcommands&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;	&lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The new argument goes in as a plain &lt;code&gt;[]string&lt;/code&gt;, sat &lt;em&gt;before&lt;/em&gt; the variadic, which is perfectly legal: one variadic, still last. Callers who care about config use &lt;code&gt;NewCmdRootWithConfig&lt;/code&gt; explicitly, and &lt;code&gt;NewCmdRoot&lt;/code&gt; becomes a one-line wrapper that delegates with an empty slice, so every existing caller compiles untouched and none the wiser. Two doors into the same room, granted, but the original door is exactly where everyone left it.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;A trailing variadic is a slot you fill once. It buys gorgeous ergonomics for the &amp;ldquo;as many as you like&amp;rdquo; argument, and in exchange it quietly forecloses on ever appending another parameter, because the next one has nowhere to stand. Once it&amp;rsquo;s spent, new arguments come in as ordinary parameters before the variadic, and the kind thing to do for your callers is to put that behind a second constructor and let the original keep delegating.&lt;/p&gt;
&lt;p&gt;So spend the variadic deliberately. Give it to the argument that genuinely wants to be a loose list, not the first one that happens to be plural, because you don&amp;rsquo;t get a second.&lt;/p&gt;</description></item><item><title>Half your users don't have eyes</title><link>https://phpboyscout.uk/half-your-users-dont-have-eyes/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/half-your-users-dont-have-eyes/</guid><description>&lt;img src="https://phpboyscout.uk/half-your-users-dont-have-eyes/cover-half-your-users-dont-have-eyes.png" alt="Featured image of post Half your users don't have eyes" /&gt;&lt;p&gt;Run a command in your favourite CLI tool and look at what comes back. Colour. Neatly aligned columns. A friendly little summary sentence. Lovely&amp;hellip; if you happen to be a human with eyes.&lt;/p&gt;
&lt;p&gt;But a good half of any tool&amp;rsquo;s users aren&amp;rsquo;t people at all. They&amp;rsquo;re scripts, CI pipelines, bits of automation. And that pretty output you&amp;rsquo;re so proud of is, to them, actively hostile.&lt;/p&gt;
&lt;h2 id="your-tool-has-two-audiences-and-only-serves-one"&gt;Your tool has two audiences and only serves one
&lt;/h2&gt;&lt;p&gt;I made more or less this same point about AI assistants when I argued that &lt;a class="link" href="https://phpboyscout.uk/your-cli-is-already-an-ai-tool/" &gt;your CLI is already an AI tool&lt;/a&gt;. The machines are users too. Here it isn&amp;rsquo;t an AI doing the calling, it&amp;rsquo;s a humble shell script, but the principle is identical.&lt;/p&gt;
&lt;p&gt;Run a CLI command and look at what comes back. Colour. Aligned columns. A friendly summary sentence. It&amp;rsquo;s designed for a person reading a terminal, and for a person reading a terminal it&amp;rsquo;s great.&lt;/p&gt;
&lt;p&gt;Now picture the other half of your users. A deploy script that needs to know which version is installed. A CI job that runs &lt;code&gt;doctor&lt;/code&gt; and wants to fail the build on one specific check. A bit of automation gluing your tool to three others. None of them have eyes. They have parsers.&lt;/p&gt;
&lt;p&gt;So what do they do with your beautiful human output? They butcher it. They &lt;code&gt;grep&lt;/code&gt; for a keyword, &lt;code&gt;awk&lt;/code&gt; out the third field, &lt;code&gt;sed&lt;/code&gt; off a prefix. It works in the demo. Then someone rewords a status line, or adds a column, or the colour codes shift, and every script downstream breaks at once. Silently, too, because a broken &lt;code&gt;grep&lt;/code&gt; returns nothing rather than an error. You changed a sentence and quietly took out somebody&amp;rsquo;s pipeline without ever knowing.&lt;/p&gt;
&lt;p&gt;The human-readable output was never the contract. It just got &lt;em&gt;used&lt;/em&gt; as one, because it was the only output there was.&lt;/p&gt;
&lt;h2 id="give-the-machines-their-own-channel"&gt;Give the machines their own channel
&lt;/h2&gt;&lt;p&gt;The fix is not to make the human output more parseable. That&amp;rsquo;s a trap. You&amp;rsquo;d be constraining prose meant for people in order to satisfy programs, and end up serving neither of them well. The fix is to give programs their own output format, declared and stable, kept well away from the prose.&lt;/p&gt;
&lt;p&gt;So every command built with go-tool-base gets a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/cmd/root/root.go#L447" target="_blank" rel="noopener"
 &gt;&lt;code&gt;--output&lt;/code&gt; flag&lt;/a&gt;. Leave it alone and you get the friendly human rendering. Pass &lt;code&gt;--output json&lt;/code&gt; and you get something a parser can actually rely on.&lt;/p&gt;
&lt;p&gt;And not just &lt;em&gt;some&lt;/em&gt; JSON. JSON with a fixed shape.&lt;/p&gt;
&lt;h2 id="one-envelope-every-command"&gt;One envelope, every command
&lt;/h2&gt;&lt;p&gt;The temptation with JSON output is to let each command emit whatever structure happens to suit it. Don&amp;rsquo;t. A consumer scripting against five of your commands then has to learn five shapes, and &amp;ldquo;where&amp;rsquo;s the actual payload?&amp;rdquo; has a different answer every single time.&lt;/p&gt;
&lt;p&gt;go-tool-base wraps every command&amp;rsquo;s JSON in one standard &lt;code&gt;Response&lt;/code&gt; envelope:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;status&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;success&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;command&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;deploy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;data&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;environment&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;production&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;version&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1.4.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;replicas&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;status&lt;/code&gt; says how it went. &lt;code&gt;command&lt;/code&gt; says what produced it. &lt;code&gt;data&lt;/code&gt; holds the command-specific payload, and &lt;em&gt;only&lt;/em&gt; the payload. Every built-in command (&lt;code&gt;version&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;init&lt;/code&gt;) emits exactly this shape. So does every command you write, because &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/output/output.go#L45" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pkg/output&lt;/code&gt;&lt;/a&gt; hands you the envelope rather than letting you freelance:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Flags&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;GetString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;output&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stdout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;StatusSuccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;deploy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The consumer-side payoff is the whole point. A script can check &lt;code&gt;.status&lt;/code&gt; without ever touching &lt;code&gt;.data&lt;/code&gt;. It can pull &lt;code&gt;.data.version&lt;/code&gt; and know the field is there because it&amp;rsquo;s typed, not scraped. It learns the envelope once, and every command in your tool, and every tool built on the framework, honours it. The contract is explicit, versioned, and the same everywhere, which is precisely what the abused human output never was.&lt;/p&gt;
&lt;h2 id="the-human-output-gets-to-relax"&gt;The human output gets to relax
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a quiet second benefit, and it&amp;rsquo;s my favourite kind: the sort you get for free. Once programs have their own reliable channel, the human output is &lt;em&gt;freed&lt;/em&gt;. It no longer has to stay accidentally parseable. You can reword a status line, add colour, restructure a table, make it genuinely nicer to read, and not break a single script, because no script is reading it any more. They&amp;rsquo;re all over on &lt;code&gt;--output json&lt;/code&gt;, where the real contract lives.&lt;/p&gt;
&lt;p&gt;Two audiences, two formats, each one actually suited to its reader. That&amp;rsquo;s the deal a CLI tool ought to be offering, and most of them don&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="in-short"&gt;In short
&lt;/h2&gt;&lt;p&gt;A CLI tool that only emits human-readable output is only half-built, because half its users are programs that end up &lt;code&gt;grep&lt;/code&gt;-ing prose and shattering the moment that prose changes. go-tool-base gives every command a &lt;code&gt;--output json&lt;/code&gt; flag and one standard &lt;code&gt;Response&lt;/code&gt; envelope (&lt;code&gt;status&lt;/code&gt;, &lt;code&gt;command&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt;) used identically by every built-in command and by anything you write through &lt;code&gt;pkg/output&lt;/code&gt;. Machines get a stable, explicit, learn-it-once contract; humans get output that&amp;rsquo;s now free to be properly readable, because nothing fragile depends on its wording any more.&lt;/p&gt;
&lt;p&gt;If your tool will ever be called by another program (and it will), give that program a front door. Don&amp;rsquo;t make it climb in through the window.&lt;/p&gt;</description></item><item><title>Lifecycle management for when your CLI grows up into a service</title><link>https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/</link><pubDate>Tue, 24 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/</guid><description>&lt;img src="https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/cover-lifecycle-management-for-long-running-go-services.png" alt="Featured image of post Lifecycle management for when your CLI grows up into a service" /&gt;&lt;p&gt;There&amp;rsquo;s a moment in the life of a lot of CLI tools where they stop being a CLI tool. Nobody quite decides it. It just happens. Someone needs the thing to also expose a little HTTP endpoint, or poll a queue, or run a scheduler, so it grows a &lt;code&gt;serve&lt;/code&gt; command&amp;hellip; and the honest command-line utility you wrote is suddenly a long-running service wearing a CLI as a hat.&lt;/p&gt;
&lt;p&gt;And a service needs a whole pile of production plumbing that a one-shot command never did.&lt;/p&gt;
&lt;h2 id="the-command-that-stops-being-a-command"&gt;The command that stops being a command
&lt;/h2&gt;&lt;p&gt;go-tool-base is CLI-first. It is not CLI-&lt;em&gt;only&lt;/em&gt;, and the reason is a pattern I&amp;rsquo;ve watched play out more times than I can count.&lt;/p&gt;
&lt;p&gt;A tool starts its life as an honest command-line utility. It runs, it does its thing, it exits. Then someone needs it to expose a small HTTP endpoint. Or poll a queue. Or run a scheduler. So it grows a &lt;code&gt;serve&lt;/code&gt; command, or a &lt;code&gt;run&lt;/code&gt; command, and the moment it does, the thing that was a CLI tool is now a long-running service that happens to have a CLI bolted on the front.&lt;/p&gt;
&lt;p&gt;And a long-running service needs a whole category of plumbing a one-shot command never did. It has to start things up in a sensible order. It has to shut them down &lt;em&gt;gracefully&lt;/em&gt; when someone sends a &lt;code&gt;SIGTERM&lt;/code&gt;, finishing in-flight work rather than dropping it on the floor. It has to tell an orchestrator whether it&amp;rsquo;s alive, and whether it&amp;rsquo;s ready. It has to do something sensible when one of its internal services quietly falls over at 3am.&lt;/p&gt;
&lt;p&gt;Hand-rolled, that&amp;rsquo;s a few hundred lines of goroutine choreography, channel-wrangling and signal handling that every such tool reinvents, slightly differently and slightly wrong each time. It&amp;rsquo;s the first-afternoon problem all over again, just turning up later in the project&amp;rsquo;s life. So go-tool-base ships it: &lt;code&gt;pkg/controls&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="a-controller-and-the-things-it-controls"&gt;A controller and the things it controls
&lt;/h2&gt;&lt;p&gt;The model is small. A &lt;code&gt;Controller&lt;/code&gt; manages any number of services. You register each with &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/controls/controller.go#L125" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Register(id, opts...)&lt;/code&gt;&lt;/a&gt; and describe it with functional options: &lt;code&gt;WithStart&lt;/code&gt; takes a &lt;code&gt;StartFunc&lt;/code&gt;, &lt;code&gt;WithStop&lt;/code&gt; a &lt;code&gt;StopFunc&lt;/code&gt;. An HTTP server, a background worker, a scheduler, anything with a &amp;ldquo;begin&amp;rdquo; and an &amp;ldquo;end&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;You register your services with the controller and it owns their collective lifecycle. They share a common set of channels (errors, OS signals, health, control messages) so the whole set can react together. A &lt;code&gt;SIGTERM&lt;/code&gt; doesn&amp;rsquo;t get caught by one service off in a corner; it reaches the controller, and the controller takes everything down in order, each &lt;code&gt;StopFunc&lt;/code&gt; handed a context with a deadline so that one sulking service can&amp;rsquo;t wedge the whole shutdown forever.&lt;/p&gt;
&lt;p&gt;That ordering and timeout handling is the bit nobody enjoys writing and everybody needs. Centralising it means a tool that adds a second service later inherits correct coordinated shutdown for free, rather than discovering on its first production &lt;code&gt;SIGTERM&lt;/code&gt; that it only half shuts down.&lt;/p&gt;
&lt;h2 id="probes-because-something-is-usually-watching"&gt;Probes, because something is usually watching
&lt;/h2&gt;&lt;p&gt;If the service ends up in Kubernetes (and a lot of them do) the orchestrator wants to ask two different questions, and they really are different questions.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Liveness:&lt;/em&gt; are you alive, or are you wedged and in need of a kill? &lt;em&gt;Readiness:&lt;/em&gt; are you alive &lt;em&gt;and&lt;/em&gt; able to take traffic right now? A service can quite easily be live but not ready&amp;hellip; still warming a cache, still waiting on a dependency. Conflate the two and you get yourself killed during a slow startup, or sent traffic before you can actually serve it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;controls&lt;/code&gt; keeps them separate. You attach a &lt;code&gt;WithLiveness&lt;/code&gt; probe and a &lt;code&gt;WithReadiness&lt;/code&gt; probe to a service, each just a function returning a health report, and the controller exposes them. The tool answers Kubernetes honestly, in Kubernetes&amp;rsquo; own terms, without you hand-wiring two more HTTP handlers.&lt;/p&gt;
&lt;h2 id="self-healing-but-only-if-you-ask"&gt;Self-healing, but only if you ask
&lt;/h2&gt;&lt;p&gt;The last piece is what happens when a service fails. A worker&amp;rsquo;s &lt;code&gt;StartFunc&lt;/code&gt; returns an error. Health checks start failing. In a hand-rolled setup this is where you either crash the whole process or write yourself a bespoke restart loop.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;controls&lt;/code&gt; has a supervisor that can restart a failed service for you, and the important word in that sentence is &lt;em&gt;can&lt;/em&gt;. It&amp;rsquo;s off by default. A service is only supervised if you hand it a &lt;code&gt;RestartPolicy&lt;/code&gt; at registration:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithRestartPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RestartPolicy&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;MaxRestarts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;InitialBackoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;MaxBackoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HealthFailureThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;With a policy in place, the controller restarts the service if its &lt;code&gt;StartFunc&lt;/code&gt; errors out, or if it racks up more consecutive health-check failures than the threshold allows. Restarts back off exponentially, from &lt;code&gt;InitialBackoff&lt;/code&gt; up to a &lt;code&gt;MaxBackoff&lt;/code&gt; ceiling, so a service that&amp;rsquo;s failing because its database is down doesn&amp;rsquo;t sit there hammering that database flat with a tight restart loop. &lt;code&gt;MaxRestarts&lt;/code&gt; caps the attempts, because a service that&amp;rsquo;s failed five times in a row is not going to be rescued by a sixth go, and at that point honest failure beats a thrashing pretence of health.&lt;/p&gt;
&lt;p&gt;Opt-in matters here. Automatic restarts are exactly right for a resilient daemon and exactly &lt;em&gt;wrong&lt;/em&gt; for a tool where a failure should stop the line and get a human&amp;rsquo;s attention. The framework doesn&amp;rsquo;t make that call for you. It gives you the supervisor and lets you point it at the services that genuinely want it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;A surprising number of CLI tools become long-running services the day they grow a &lt;code&gt;serve&lt;/code&gt; command, and the day they do, they need coordinated startup, graceful ordered shutdown, real liveness and readiness probes, and a considered answer to a service falling over. That&amp;rsquo;s a few hundred lines of fiddly, easy-to-get-wrong plumbing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pkg/controls&lt;/code&gt; provides it: a &lt;code&gt;Controller&lt;/code&gt; over &lt;code&gt;Controllable&lt;/code&gt; services with shared channels and deadline-bounded graceful shutdown, separate Kubernetes-style liveness and readiness probes, and an opt-in supervisor that restarts failed services with exponential backoff and a restart ceiling. Your tool can start as a command and grow into a daemon without that growth turning into a rewrite.&lt;/p&gt;
&lt;p&gt;CLI-first, but not stuck there.&lt;/p&gt;</description></item><item><title>Middleware for CLI commands, not just web servers</title><link>https://phpboyscout.uk/middleware-for-cli-commands-not-just-web-servers/</link><pubDate>Tue, 24 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/middleware-for-cli-commands-not-just-web-servers/</guid><description>&lt;img src="https://phpboyscout.uk/middleware-for-cli-commands-not-just-web-servers/cover-middleware-for-cli-commands-not-just-web-servers.png" alt="Featured image of post Middleware for CLI commands, not just web servers" /&gt;&lt;p&gt;Every CLI tool past a certain size grows a category of logic that doesn&amp;rsquo;t really belong to any one command, and yet has to happen for loads of them. Timing. An auth check. Panic recovery, so a crash becomes a clean error instead of a stack-trace all over someone&amp;rsquo;s terminal. A log line saying the command started and how it finished.&lt;/p&gt;
&lt;p&gt;Web frameworks sorted this out years ago. CLIs, for some reason, mostly still copy-paste it around.&lt;/p&gt;
&lt;h2 id="the-logic-that-belongs-to-no-single-command"&gt;The logic that belongs to no single command
&lt;/h2&gt;&lt;p&gt;That category of logic doesn&amp;rsquo;t belong to any one command, yet needs to happen for many of them. Time how long the command took. Check the user is authenticated before a command that needs it. Recover from a panic so a crash becomes a clean error rather than a stack-trace vomited across the screen. Log that the command started and how it ended.&lt;/p&gt;
&lt;p&gt;None of that is the command&amp;rsquo;s &lt;em&gt;job&lt;/em&gt;. The &lt;code&gt;deploy&lt;/code&gt; command&amp;rsquo;s job is to deploy. But timing and recovery and auth still have to happen around it, and around &lt;code&gt;build&lt;/code&gt;, and around &lt;code&gt;sync&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Put that logic &lt;em&gt;inside&lt;/em&gt; each command&amp;rsquo;s &lt;code&gt;RunE&lt;/code&gt; and you&amp;rsquo;ve copied the same six lines into thirty functions, which means thirty places to fix when the logging format changes and thirty chances to forget one of them. Cross-cutting concerns copied by hand don&amp;rsquo;t stay consistent. They drift, every time.&lt;/p&gt;
&lt;h2 id="web-frameworks-already-solved-this"&gt;Web frameworks already solved this
&lt;/h2&gt;&lt;p&gt;This is not a new problem. It&amp;rsquo;s about the oldest problem in web frameworks, and they settled on an answer a long time ago: middleware. Gin has it, Echo has it, every HTTP stack you&amp;rsquo;ve ever touched has it. A middleware is a wrapper that sits around a handler, runs its cross-cutting logic, and calls through to the handler in the middle.&lt;/p&gt;
&lt;p&gt;A CLI command is, structurally, just a handler too. So go-tool-base brings the same pattern to the Cobra command tree, with the same functional &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/setup/middleware.go#L14" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Chain&lt;/code&gt;&lt;/a&gt; shape:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Middleware&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;A middleware receives the &lt;em&gt;next&lt;/em&gt; handler in the chain and returns a new handler that wraps it. You compose a stack of them, and each command&amp;rsquo;s real &lt;code&gt;RunE&lt;/code&gt; runs in the middle of the onion. Write the timing logic once, as one middleware, and every command in the chain is timed. Change the log format once and all thirty commands change with it, because there was only ever one copy. (The &amp;ldquo;write it once, in a place where everyone inherits it&amp;rdquo; drum again, which I will keep banging until the series runs out.)&lt;/p&gt;
&lt;h2 id="but-cobra-already-has-prerun"&gt;&amp;ldquo;But Cobra already has PreRun&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;It does, and this is the objection worth answering properly, because Cobra ships &lt;code&gt;PersistentPreRun&lt;/code&gt; and &lt;code&gt;PreRun&lt;/code&gt; hooks and they look, at a glance, like they cover this.&lt;/p&gt;
&lt;p&gt;They don&amp;rsquo;t, and the reason is structural. A &lt;code&gt;PreRun&lt;/code&gt; hook is a thing that happens &lt;em&gt;before&lt;/em&gt; the command. That&amp;rsquo;s all it is. It can&amp;rsquo;t run anything &lt;em&gt;after&lt;/em&gt;. It can&amp;rsquo;t wrap the command in a &lt;code&gt;defer&lt;/code&gt;. It can&amp;rsquo;t catch a panic the command throws. It can&amp;rsquo;t measure how long the command took, because measuring a duration needs a start point &lt;em&gt;and&lt;/em&gt; an end point, and the hook only owns the start.&lt;/p&gt;
&lt;p&gt;A middleware wraps the &lt;em&gt;entire&lt;/em&gt; execution. Because it&amp;rsquo;s a function that calls &lt;code&gt;next()&lt;/code&gt; in its own body, it straddles the command (with the handler signature abbreviated to &lt;code&gt;HandlerFunc&lt;/code&gt; here for readability):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;TimingMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;HandlerFunc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// the command runs here&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;command finished&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;took&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Before, after, and around. A recovery middleware can put a &lt;code&gt;defer recover()&lt;/code&gt; in place that a &lt;code&gt;PreRun&lt;/code&gt; hook structurally cannot. An auth middleware can check a condition and return an error &lt;em&gt;instead of calling &lt;code&gt;next()&lt;/code&gt; at all&lt;/em&gt;, refusing to let the command run in the first place. &lt;code&gt;PreRun&lt;/code&gt; can&amp;rsquo;t veto the command; it runs, and then the command runs regardless.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;PreRun&lt;/code&gt; is a notification that the command is about to happen. Middleware is control over whether and how it happens. For genuine cross-cutting concerns you need the second thing, not the first.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;Timing, auth, recovery and logging are cross-cutting concerns: necessary for many commands, owned by none. Hand-copied into every &lt;code&gt;RunE&lt;/code&gt;, they drift out of sync. Web frameworks fixed this with middleware years ago, and a CLI command is structurally just another handler.&lt;/p&gt;
&lt;p&gt;go-tool-base brings the functional Chain middleware pattern to the Cobra command tree. A middleware wraps a command&amp;rsquo;s whole execution, so it acts before and after and can decide whether the command runs at all&amp;hellip; strictly more than Cobra&amp;rsquo;s &lt;code&gt;PreRun&lt;/code&gt; hooks, which only fire beforehand and can&amp;rsquo;t wrap, recover, time, or veto. Write the concern once, wrap the chain, and every command inherits it consistently.&lt;/p&gt;</description></item><item><title>A logging interface that doesn't leak its backend</title><link>https://phpboyscout.uk/a-logging-interface-that-doesnt-leak-its-backend/</link><pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-logging-interface-that-doesnt-leak-its-backend/</guid><description>&lt;img src="https://phpboyscout.uk/a-logging-interface-that-doesnt-leak-its-backend/cover-a-logging-interface-that-doesnt-leak-its-backend.png" alt="Featured image of post A logging interface that doesn't leak its backend" /&gt;&lt;p&gt;The same tool, in two different lives, wants two completely different kinds of log.&lt;/p&gt;
&lt;p&gt;On my laptop I want logs I can actually read: colour, alignment, friendly timestamps. The very same tool running as a daemon in a container wants none of that. It wants structured JSON, one object a line, ready for a log aggregator to swallow. And in a test I want the logger to shut up entirely. The interesting question is what it costs you to move between the three.&lt;/p&gt;
&lt;h2 id="the-same-tool-wants-different-logs"&gt;The same tool wants different logs
&lt;/h2&gt;&lt;p&gt;On a developer&amp;rsquo;s machine the tool is a CLI. You want logs that are pleasant to read in a terminal: colour, alignment, human-friendly timestamps. The charmbracelet logger does that beautifully.&lt;/p&gt;
&lt;p&gt;Then the very same tool grows a &lt;code&gt;serve&lt;/code&gt; command and gets deployed as a daemon in a container. Now coloured terminal output is worse than useless. The log aggregator wants structured JSON, one object per line, machine-parseable. &lt;code&gt;slog&lt;/code&gt; does that.&lt;/p&gt;
&lt;p&gt;And in tests you want neither. You want the logger to exist, satisfy the interface, and stay completely silent.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s three different logging backends, wanted by one tool across three different lives. So what does switching between them actually cost?&lt;/p&gt;
&lt;h2 id="what-it-costs-depends-on-what-your-packages-imported"&gt;What it costs depends on what your packages imported
&lt;/h2&gt;&lt;p&gt;If your packages import a concrete logger, if &lt;code&gt;pkg/config&lt;/code&gt; and &lt;code&gt;pkg/setup&lt;/code&gt; and twenty others each have &lt;code&gt;import &amp;quot;github.com/charmbracelet/log&amp;quot;&lt;/code&gt; and take a &lt;code&gt;*log.Logger&lt;/code&gt;, then the backend is welded into the entire codebase. Switching to JSON for the container build means editing the import and the parameter type in every single one of those packages. The backend has &lt;em&gt;leaked&lt;/em&gt;. A detail that should have been one decision has become a property of a hundred files.&lt;/p&gt;
&lt;p&gt;go-tool-base doesn&amp;rsquo;t let it leak. Every package in the framework accepts a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/logger/logger.go#L16" target="_blank" rel="noopener"
 &gt;&lt;code&gt;logger.Logger&lt;/code&gt;&lt;/a&gt;, an interface, and nothing else. No package anywhere imports a concrete logging library. A package states, in its types, &amp;ldquo;I need something I can log through&amp;rdquo;, and stops right there. It has no idea, and no way to find out, what&amp;rsquo;s actually on the other end.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// what every package depends on&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kd"&gt;interface&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;keyvals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;keyvals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;keyvals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;keyvals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The backend gets chosen once, at the top, when the tool builds its &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt;. It travels down to every package as the interface, through the &lt;code&gt;Props&lt;/code&gt; container. The packages underneath never see the concrete type, so the concrete type can change without a single one of them noticing. (There&amp;rsquo;s that &amp;ldquo;decide it once, in one place&amp;rdquo; theme again. I did warn you it runs through everything.)&lt;/p&gt;
&lt;h2 id="three-backends-and-the-swap-is-one-line"&gt;Three backends, and the swap is one line
&lt;/h2&gt;&lt;p&gt;go-tool-base ships three implementations of that interface:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;charmbracelet&lt;/strong&gt; (&lt;code&gt;logger.NewCharm(w, opts...)&lt;/code&gt;). Coloured, styled, for humans at a terminal. The CLI default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;slog JSON&lt;/strong&gt;, a &lt;code&gt;slog&lt;/code&gt;-backed backend emitting structured JSON, for daemons and containers feeding a log aggregator.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;noop&lt;/strong&gt;, which does precisely nothing, for tests that want a real &lt;code&gt;Logger&lt;/code&gt; and total silence.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Switching the tool from a friendly CLI logger to container-ready JSON is a change to the one line in &lt;code&gt;main()&lt;/code&gt; that constructs the logger. That&amp;rsquo;s the lot. &lt;code&gt;pkg/config&lt;/code&gt; doesn&amp;rsquo;t change. &lt;code&gt;pkg/setup&lt;/code&gt; doesn&amp;rsquo;t change. None of the twenty packages change, because none of them ever knew which backend they had. The decision was always one line; the interface is what &lt;em&gt;kept&lt;/em&gt; it one line.&lt;/p&gt;
&lt;p&gt;The noop backend deserves its own mention, because it&amp;rsquo;s the one people underrate. A test for a command shouldn&amp;rsquo;t be spraying log output all over the test run, but the command still needs a non-nil &lt;code&gt;Logger&lt;/code&gt; to function. &lt;code&gt;logger.NewNoop()&lt;/code&gt; gives you exactly that: interface satisfied, output binned, test quiet. And because it&amp;rsquo;s just another implementation of the same interface, no test needs any special logging machinery. It passes a different backend, exactly the way the container build does.&lt;/p&gt;
&lt;h2 id="the-general-shape"&gt;The general shape
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s nothing exotic going on here. It&amp;rsquo;s &amp;ldquo;depend on interfaces, not implementations&amp;rdquo;, which every Go developer has had drilled into them at some point. The bit worth holding onto is &lt;em&gt;where&lt;/em&gt; the rule actually pays out, and it&amp;rsquo;s at the seams between a stable core and a detail you know full well you&amp;rsquo;ll want to vary.&lt;/p&gt;
&lt;p&gt;A logging backend is exactly such a detail. You will want it different in a terminal, in a container, and in a test. So the thing your code depends on has to be the interface, and the concrete backend has to be chosen at one well-known point and nowhere else. Get that boundary right and &amp;ldquo;we need JSON logs in production&amp;rdquo; is a one-line change. Get it wrong and it&amp;rsquo;s a refactor and a bad afternoon.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;One tool legitimately wants three different logging backends across its life: coloured output in a terminal, structured JSON in a container, silence in a test. The cost of moving between them is decided entirely by whether your packages imported a concrete logger or an interface.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s packages depend only on &lt;code&gt;logger.Logger&lt;/code&gt;, never a backend. Three implementations ship (charmbracelet, &lt;code&gt;slog&lt;/code&gt; JSON, noop) and the backend is chosen once, in &lt;code&gt;main()&lt;/code&gt;, then carried everywhere as the interface through &lt;code&gt;Props&lt;/code&gt;. Switching is one line at the top, because the detail was never allowed to leak into the hundred files below it.&lt;/p&gt;</description></item><item><title>Errors that tell the user what to do next</title><link>https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/</guid><description>&lt;img src="https://phpboyscout.uk/errors-that-tell-the-user-what-to-do-next/cover-errors-that-tell-the-user-what-to-do-next.png" alt="Featured image of post Errors that tell the user what to do next" /&gt;&lt;p&gt;Here&amp;rsquo;s an error message I&amp;rsquo;ve been on the receiving end of more times than I&amp;rsquo;d care to count:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;error: failed to read config file
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;True. Also completely useless! I now know something is broken and I haven&amp;rsquo;t the faintest idea what to do about it. Which file? Why couldn&amp;rsquo;t it be read? Should I create it, run some &lt;code&gt;init&lt;/code&gt; command, fix a permission, set an environment variable? The message states the problem and then abandons me at it, rather like a sat-nav cheerfully announcing &amp;ldquo;you have arrived&amp;rdquo; in the middle of a motorway.&lt;/p&gt;
&lt;h2 id="a-message-is-not-a-fix"&gt;A message is not a fix
&lt;/h2&gt;&lt;p&gt;The instinct, the moment you notice this, is to go and write a &lt;em&gt;better message&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="o"&gt;~/.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mytool&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Run&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;mytool init&amp;#39;&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;MYTOOL_CONFIG&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;point&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Better for the human, no question. But look at what you&amp;rsquo;ve just done to the error as a &lt;em&gt;value&lt;/em&gt;. The recovery advice is now welded into the error string. Any code that wants to ask &amp;ldquo;is this the config-missing error?&amp;rdquo; is reduced to substring-matching English prose. Reword the advice and you break the check. So you&amp;rsquo;ve helped the user and quietly sabotaged the program at the same time, because you&amp;rsquo;ve made one poor little string do two completely incompatible jobs&amp;hellip; being a stable identity for code, and being friendly guidance for people.&lt;/p&gt;
&lt;h2 id="why-i-changed-error-libraries"&gt;Why I changed error libraries
&lt;/h2&gt;&lt;p&gt;go-tool-base started out on &lt;code&gt;github.com/go-errors/errors&lt;/code&gt;. It&amp;rsquo;s a perfectly fine library and it gave us stack traces. What it didn&amp;rsquo;t give us was any way to attach human guidance to an error without shoving it into the message string. So the codebase did exactly the daft thing I just described: multi-line suggestion text baked straight into &lt;code&gt;errors.Errorf&lt;/code&gt; calls, user-facing content and programmatic identity all mashed into one value.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the whole reason for the migration to &lt;code&gt;github.com/cockroachdb/errors&lt;/code&gt;. Not novelty, and not because I fancied a weekend of find-and-replace. One specific capability: &lt;code&gt;cockroachdb/errors&lt;/code&gt; lets you &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/chat/baseurl.go#L113" target="_blank" rel="noopener"
 &gt;attach a &lt;strong&gt;hint&lt;/strong&gt;&lt;/a&gt; to an error as a separate, structured field.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;failed to read config file&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;Run &amp;#39;mytool init&amp;#39; to create one, or set MYTOOL_CONFIG to point at an existing file.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now there are two things, cleanly apart. &lt;code&gt;errors.New(&amp;quot;failed to read config file&amp;quot;)&lt;/code&gt; is the &lt;em&gt;identity&lt;/em&gt;&amp;hellip; stable, matchable, the program&amp;rsquo;s handle on the error. The hint is the &lt;em&gt;guidance&lt;/em&gt;&amp;hellip; for the human, and rewordable as much as you like without breaking a single check, because no check ever looks at it. &lt;code&gt;errors.Is&lt;/code&gt; and &lt;code&gt;errors.As&lt;/code&gt; work properly through every wrapper layer, so code matches on identity and never has to read prose.&lt;/p&gt;
&lt;p&gt;The migration brought a few other things worth having. Stack traces print with a plain &lt;code&gt;%+v&lt;/code&gt; instead of a type assertion. Errors can carry structured, machine-readable metadata. Multiple errors from concurrent work can be combined as a first-class value. But the hint is the one that actually changed the user&amp;rsquo;s day, because the hint is the recovery step, stored where it belongs.&lt;/p&gt;
&lt;h2 id="one-door-out-and-it-knows-where-the-help-is"&gt;One door out, and it knows where the help is
&lt;/h2&gt;&lt;p&gt;Separating the hint is only half of it. The other half is making sure those hints actually reach the user, every time, and that comes down to having a single way out.&lt;/p&gt;
&lt;p&gt;Every go-tool-base command returns its errors the idiomatic Cobra way, through &lt;code&gt;RunE&lt;/code&gt;. They all funnel into one &lt;code&gt;Execute()&lt;/code&gt; wrapper at the root, which routes every error (runtime failure, flag parse error, pre-run failure) through one &lt;code&gt;ErrorHandler&lt;/code&gt;. One door out. So error &lt;em&gt;presentation&lt;/em&gt; gets decided in exactly one place, and no command can render an error differently from the command sat next to it.&lt;/p&gt;
&lt;p&gt;And because there&amp;rsquo;s one handler, it can pull off something the individual commands never could. The framework knows your tool&amp;rsquo;s metadata, including its configured support channel, be it a Slack workspace or a Teams channel. So the error handler can finish a fatal error not just with the &lt;em&gt;what&lt;/em&gt; and the recovery hint, but with &lt;em&gt;where to go if the hint didn&amp;rsquo;t help&lt;/em&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt; &lt;span class="s1"&gt;&amp;#39;mytool init&amp;#39;&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;create&lt;/span&gt; &lt;span class="n"&gt;one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;set&lt;/span&gt; &lt;span class="n"&gt;MYTOOL_CONFIG&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;Still&lt;/span&gt; &lt;span class="n"&gt;stuck&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Ask&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="c1"&gt;#mytool-support on Slack.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The user is never left at a dead end. The error tells them what broke, the hint tells them the most likely fix, and if that&amp;rsquo;s still not enough the handler tells them which door to go and knock on. A failure becomes a signpost instead of a full stop.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;An error that only reports what went wrong leaves the user stranded, and the obvious fix (writing the recovery advice into the message) quietly wrecks the error as a value, because now your code has to substring-match prose just to work out what it&amp;rsquo;s looking at.&lt;/p&gt;
&lt;p&gt;go-tool-base moved from &lt;code&gt;go-errors&lt;/code&gt; to &lt;code&gt;cockroachdb/errors&lt;/code&gt; to get hints: a structured, separate field for human guidance that leaves the error&amp;rsquo;s identity clean for &lt;code&gt;errors.Is&lt;/code&gt; and &lt;code&gt;errors.As&lt;/code&gt;. Every command&amp;rsquo;s errors leave through one &lt;code&gt;Execute()&lt;/code&gt; wrapper and one &lt;code&gt;ErrorHandler&lt;/code&gt;, so presentation stays consistent, and because that handler knows the tool&amp;rsquo;s support channel it can point a stuck user at real help.&lt;/p&gt;
&lt;p&gt;State the problem for the program. Give the fix to the human. And for pity&amp;rsquo;s sake, keep the two in different fields.&lt;/p&gt;</description></item><item><title>Many embedded filesystems, one merged view</title><link>https://phpboyscout.uk/many-embedded-filesystems-one-merged-view/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/many-embedded-filesystems-one-merged-view/</guid><description>&lt;img src="https://phpboyscout.uk/many-embedded-filesystems-one-merged-view/cover-many-embedded-filesystems-one-merged-view.png" alt="Featured image of post Many embedded filesystems, one merged view" /&gt;&lt;p&gt;Go&amp;rsquo;s &lt;code&gt;embed&lt;/code&gt; package is one of those features that makes you slightly giddy the first time you use it. One &lt;code&gt;//go:embed&lt;/code&gt; directive and your default config, your templates, your docs are all baked into the binary. The tool just works the moment it&amp;rsquo;s installed, with nothing external to lose or forget to ship.&lt;/p&gt;
&lt;p&gt;And then you go and build something modular on top of it, and you discover the catch nobody warned you about.&lt;/p&gt;
&lt;h2 id="embedfs-is-an-island"&gt;&lt;code&gt;embed.FS&lt;/code&gt; is an island
&lt;/h2&gt;&lt;p&gt;An &lt;code&gt;embed.FS&lt;/code&gt; has a property that&amp;rsquo;s easy to miss until it bites: it&amp;rsquo;s local to the package that declared it. The &lt;code&gt;//go:embed&lt;/code&gt; directive can only see files at or below its own source file. So in any project bigger than a toy, you don&amp;rsquo;t have &lt;em&gt;an&lt;/em&gt; embedded filesystem. You have many. The root package embeds one. Each feature, each subcommand that ships its own templates or defaults, embeds another. They&amp;rsquo;re islands, one per package, and Go gives you no native way to make them behave as a whole.&lt;/p&gt;
&lt;p&gt;For most files that&amp;rsquo;s perfectly fine. A feature&amp;rsquo;s templates can stay on the feature&amp;rsquo;s island; nothing else needs them.&lt;/p&gt;
&lt;p&gt;It stops being fine the moment features need to contribute to something shared.&lt;/p&gt;
&lt;h2 id="the-shared-config-problem"&gt;The shared-config problem
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the case that forces the issue. A go-tool-base tool has a global &lt;code&gt;config.yaml&lt;/code&gt; of defaults, embedded at the root. Now you add a feature, and that feature has its own configuration keys, with their own sensible defaults.&lt;/p&gt;
&lt;p&gt;Where do those defaults go?&lt;/p&gt;
&lt;p&gt;The naive answer is: edit the root &lt;code&gt;config.yaml&lt;/code&gt; and add the feature&amp;rsquo;s section. And that&amp;rsquo;s a genuinely bad answer, because it inverts the dependency. The root config now has to know about every feature. Add a feature, edit the centre. Remove one, edit the centre again. The central file becomes a pinch point that every feature has to reach into, and a modular architecture where you can&amp;rsquo;t add a module without editing the core isn&amp;rsquo;t really modular at all&amp;hellip; it just has more files.&lt;/p&gt;
&lt;p&gt;What you actually want is for the feature to ship its own slice of default config, on its own island, and for the global config the tool reads to somehow already contain it. The feature contributes; the centre doesn&amp;rsquo;t budge.&lt;/p&gt;
&lt;h2 id="propsassets-merge-the-islands"&gt;&lt;code&gt;props.Assets&lt;/code&gt;: merge the islands
&lt;/h2&gt;&lt;p&gt;That&amp;rsquo;s the job of &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/assets.go#L30" target="_blank" rel="noopener"
 &gt;&lt;code&gt;props.Assets&lt;/code&gt;&lt;/a&gt;. (Yes, it lives on &lt;a class="link" href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" &gt;Props&lt;/a&gt;, the load-bearing container I keep going on about. Most of the good stuff does.) It&amp;rsquo;s a layer that implements the standard &lt;code&gt;fs.FS&lt;/code&gt; interface, and into it you &lt;code&gt;Register&lt;/code&gt; each &lt;code&gt;embed.FS&lt;/code&gt; under a name:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// root main.go&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;Assets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewAssets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AssetMap&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;root&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// a feature&amp;#39;s command constructor&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;//go:embed assets/*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdFeature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;feature&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;assets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now &lt;code&gt;Props&lt;/code&gt; carries one &lt;code&gt;Assets&lt;/code&gt; value that represents all the islands as a single filesystem. The root&amp;rsquo;s files and every registered feature&amp;rsquo;s files, addressable through one &lt;code&gt;fs.FS&lt;/code&gt;. Each registration is named, so the islands stay individually identifiable, but they read as one.&lt;/p&gt;
&lt;p&gt;That alone solves the addressing problem. The genuinely clever part is what happens for structured files.&lt;/p&gt;
&lt;h2 id="opening-a-file-that-exists-in-several-places"&gt;Opening a file that exists in several places
&lt;/h2&gt;&lt;p&gt;When you &lt;code&gt;Open&lt;/code&gt; a path through &lt;code&gt;props.Assets&lt;/code&gt; and that path has a structured extension it recognises (&lt;code&gt;.yaml&lt;/code&gt;, &lt;code&gt;.yml&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt;, &lt;code&gt;.toml&lt;/code&gt;, &lt;code&gt;.csv&lt;/code&gt;, and a few more) it doesn&amp;rsquo;t simply return the first match it stumbles across. It does this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discovery.&lt;/strong&gt; It finds every instance of that path, across every registered filesystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parsing.&lt;/strong&gt; It unmarshals each one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Merging.&lt;/strong&gt; It deep-merges the parsed data, using &lt;code&gt;mergo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-serialisation.&lt;/strong&gt; It hands you back a single &lt;code&gt;fs.File&lt;/code&gt; whose contents are the combined, merged result.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So picture the shared-config problem again, only solved this time. The root ships a &lt;code&gt;config.yaml&lt;/code&gt; with the base defaults. Each feature ships a &lt;code&gt;config.yaml&lt;/code&gt; on its own island carrying only its own keys. Nobody edits anybody else&amp;rsquo;s file. When the &lt;code&gt;init&lt;/code&gt; command opens &lt;code&gt;config.yaml&lt;/code&gt; through &lt;code&gt;props.Assets&lt;/code&gt;, it doesn&amp;rsquo;t get the root&amp;rsquo;s copy. It gets the deep-merge of the root&amp;rsquo;s copy and every registered feature&amp;rsquo;s copy: one &lt;code&gt;config.yaml&lt;/code&gt; that contains every default in the tool, assembled at runtime from contributions that never knew about each other.&lt;/p&gt;
&lt;p&gt;A feature contributes its defaults simply by existing and registering. The centre never changes. That&amp;rsquo;s the modular property the naive approach couldn&amp;rsquo;t give you, and it generalises well beyond config&amp;hellip; the same merge applies to a shared &lt;code&gt;commands.csv&lt;/code&gt;, or any structured file features want to add rows or keys to.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a &lt;code&gt;Mount&lt;/code&gt; method for attaching an arbitrary &lt;code&gt;fs.FS&lt;/code&gt; at a virtual path, which is handy for surfacing something external (a temp directory, say) as part of the same tree. But the structured merge is the feature that really earns &lt;code&gt;Assets&lt;/code&gt; its place.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;embed.FS&lt;/code&gt; is per-package by design, so a modular CLI ends up with many embedded filesystems, one island per feature. Most of the time that&amp;rsquo;s fine. It fails specifically when features need to contribute to a shared resource like the global &lt;code&gt;config.yaml&lt;/code&gt;, because the naive fix forces every feature to reach in and edit a central file.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;props.Assets&lt;/code&gt; merges all the registered islands into a single &lt;code&gt;fs.FS&lt;/code&gt;, and for structured files it goes further: opening a &lt;code&gt;.yaml&lt;/code&gt;, &lt;code&gt;.json&lt;/code&gt; or &lt;code&gt;.csv&lt;/code&gt; discovers every copy across every island, deep-merges them, and returns the combined whole. A feature drops its own defaults onto its own island, registers, and the merged config the tool reads already includes them. Contribution without coupling, which is rather the whole point of being modular in the first place.&lt;/p&gt;</description></item><item><title>Props: the container that does the heavy lifting</title><link>https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/</guid><description>&lt;img src="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/cover-props.png" alt="Featured image of post Props: the container that does the heavy lifting" /&gt;&lt;p&gt;I name-dropped &lt;code&gt;Props&lt;/code&gt; back in the &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;introduction&lt;/a&gt; and then rather glossed over it, which was a bit unfair of me, because it&amp;rsquo;s the single most important design decision in the whole framework. So let&amp;rsquo;s give it the attention it actually deserves.&lt;/p&gt;
&lt;p&gt;And the best place to start, oddly enough, is the name.&lt;/p&gt;
&lt;h2 id="start-with-the-name"&gt;Start with the name
&lt;/h2&gt;&lt;p&gt;The container at the centre of go-tool-base is called &lt;code&gt;Props&lt;/code&gt;, and the name is doing real work, so we&amp;rsquo;ll start there.&lt;/p&gt;
&lt;p&gt;It is not short for &amp;ldquo;properties&amp;rdquo;, though it does hold a few. A &lt;em&gt;prop&lt;/em&gt; is the heavy timber or steel beam that stops a structure quietly collapsing in on itself. And for anyone who follows the rugby: a prop is the position in the scrum, the broad-shouldered forward whose entire job is to provide structural support so everyone else can get on with the game.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the design brief, in a single word. &lt;code&gt;Props&lt;/code&gt; is not where the clever, flashy work happens. It scores no tries. It&amp;rsquo;s the thankless, load-bearing thing that holds the framework up so that your actual command logic gets to be the interesting part. Understand the name and you understand what the struct is &lt;em&gt;for&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="what-it-carries"&gt;What it carries
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/props.go#L15" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Props&lt;/code&gt;&lt;/a&gt; is the single object passed to every command constructor in a go-tool-base tool. It holds the dependencies a command might need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Tool&lt;/code&gt;, metadata about the CLI (name, summary, release source).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Logger&lt;/code&gt;, the logging abstraction.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Config&lt;/code&gt;, the loaded configuration container.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FS&lt;/code&gt;, a filesystem abstraction (&lt;code&gt;afero&lt;/code&gt;), so a command never touches the real disk directly.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Assets&lt;/code&gt;, the embedded-resource manager.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Version&lt;/code&gt;, build information.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ErrorHandler&lt;/code&gt;, the centralised error reporter.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Collector&lt;/code&gt;, the telemetry collector (always present, a no-op when telemetry is off).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A command constructor&amp;rsquo;s signature is, accordingly, boring on purpose:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;NewCmdExample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;cobra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Command&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;One parameter. Everything the command could possibly need is reachable through it. No globals, no &lt;code&gt;init()&lt;/code&gt;-time wiring, no twelve-argument constructor that quietly grows a thirteenth argument next month.&lt;/p&gt;
&lt;h2 id="why-a-struct-and-not-contextcontext"&gt;Why a struct, and not &lt;code&gt;context.Context&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the design decision I actually want to defend, because it&amp;rsquo;s the one Go developers tend to raise an eyebrow at. Go already has a well-known way to carry things through a call tree: &lt;code&gt;context.Context&lt;/code&gt;. So why not just put the logger and the config in the context and pass that around?&lt;/p&gt;
&lt;p&gt;Because &lt;code&gt;context.Context&lt;/code&gt; carries its values as &lt;code&gt;interface{}&lt;/code&gt;, and that&amp;rsquo;s the wrong trade for &lt;em&gt;dependencies&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Pull a dependency out of a context and you get this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;logger&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;).(&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// a runtime type assertion&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That one line has two separate ways to hurt you. The key is a bare string, so a typo compiles perfectly happily and then fails at runtime. The type assertion is unchecked, so if the wrong thing is sitting under that key, your tool panics in front of a user. Neither failure is visible to the compiler. Neither is visible to your IDE. You find out when it breaks, which is to say at the worst possible time.&lt;/p&gt;
&lt;p&gt;Pull the same dependency out of &lt;code&gt;Props&lt;/code&gt; and you get this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;starting&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// a field access&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;p.Logger&lt;/code&gt; is a typed field. If it doesn&amp;rsquo;t exist, or you&amp;rsquo;ve used it wrong, the code simply doesn&amp;rsquo;t compile. Your IDE autocompletes it. Refactor the &lt;code&gt;Logger&lt;/code&gt; interface and every misuse lights up at build time. There&amp;rsquo;s no runtime type assertion, because there&amp;rsquo;s no &lt;code&gt;interface{}&lt;/code&gt; to assert from in the first place.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;context.Context&lt;/code&gt; is the right tool for what it was designed for: cancellation, deadlines, request-scoped signals that genuinely cross API boundaries. It&amp;rsquo;s the wrong tool for &amp;ldquo;here are my program&amp;rsquo;s services&amp;rdquo;, because it trades away the compiler&amp;rsquo;s help for a flexibility you really don&amp;rsquo;t want here. Dependencies should be &lt;em&gt;declared&lt;/em&gt;, somewhere the compiler checks them. &lt;code&gt;Props&lt;/code&gt; is that somewhere.&lt;/p&gt;
&lt;h2 id="what-you-get-back-for-it"&gt;What you get back for it
&lt;/h2&gt;&lt;p&gt;That one decision pays out in three currencies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Testability.&lt;/strong&gt; A command is now a pure function of its &lt;code&gt;Props&lt;/code&gt;. To test it, you build a &lt;code&gt;Props&lt;/code&gt; with the doubles you want (an in-memory &lt;code&gt;FS&lt;/code&gt; instead of the real disk, a no-op &lt;code&gt;Logger&lt;/code&gt;, a config you&amp;rsquo;ve populated by hand) and call the constructor. No global state to reset between tests, no monkey-patching, no &lt;code&gt;init()&lt;/code&gt; order to puzzle over. The dependency is an argument, so the test just passes a different one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; Cross-cutting changes have exactly one place to happen. When the global &lt;code&gt;--debug&lt;/code&gt; flag flips the log level, it does so on the &lt;code&gt;Logger&lt;/code&gt; inside &lt;code&gt;Props&lt;/code&gt;, and because every command reads its logger from the same &lt;code&gt;Props&lt;/code&gt;, every command gets the new level. No command can drift, because none of them owns its own copy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extensibility.&lt;/strong&gt; Adding a new framework-wide service is just adding a field to one struct. Every command can immediately reach it; none of them needed touching to make it reachable.&lt;/p&gt;
&lt;h2 id="to-sum-up"&gt;To sum up
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;Props&lt;/code&gt; is the dependency-injection container at the heart of go-tool-base: one struct, passed to every command, holding the logger, config, filesystem, assets, error handler and tool metadata. It&amp;rsquo;s a concrete struct rather than a &lt;code&gt;context.Context&lt;/code&gt; payload entirely on purpose, because dependencies belong somewhere the compiler can check them, not behind a string key and a hopeful runtime type assertion. That single choice buys you testability, consistency and easy extension.&lt;/p&gt;
&lt;p&gt;The name says it best, really. &lt;code&gt;Props&lt;/code&gt; doesn&amp;rsquo;t score the tries. It&amp;rsquo;s the broad-shouldered thing in the scrum that stops the whole framework folding, so the rest of your code is free to go and play.&lt;/p&gt;</description></item><item><title>Design your whole CLI in one file</title><link>https://phpboyscout.uk/design-your-whole-cli-in-one-file/</link><pubDate>Fri, 20 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/design-your-whole-cli-in-one-file/</guid><description>&lt;img src="https://phpboyscout.uk/design-your-whole-cli-in-one-file/cover-design-your-whole-cli-in-one-file.png" alt="Featured image of post Design your whole CLI in one file" /&gt;&lt;p&gt;Here&amp;rsquo;s a question that sounds trivial and really isn&amp;rsquo;t: where, exactly, does a CLI tool&amp;rsquo;s &lt;em&gt;structure&lt;/em&gt; live? Not the logic of each command&amp;hellip; the structure. Which commands exist, what they&amp;rsquo;re called, which flags they take, what&amp;rsquo;s nested under what.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d never properly thought to ask it until go-tool-base forced me to, and the answer turned out to be a little bit embarrassing.&lt;/p&gt;
&lt;h2 id="where-does-a-clis-structure-actually-live"&gt;Where does a CLI&amp;rsquo;s structure actually live?
&lt;/h2&gt;&lt;p&gt;Picture a CLI tool with twenty commands, some nested under others. In a typical project, where does its structure live? The answer is &amp;ldquo;smeared across the codebase&amp;rdquo;. It&amp;rsquo;s in twenty &lt;code&gt;cmd.go&lt;/code&gt; files. It&amp;rsquo;s in the &lt;code&gt;AddCommand&lt;/code&gt; calls that stitch them together. It&amp;rsquo;s in the flag registrations. To understand the shape of the tool you have to read all of it and assemble the picture in your head, because the picture exists nowhere as a single thing you can point at.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a strange state of affairs for the single most important design fact about a CLI. The command tree is the tool&amp;rsquo;s interface, it&amp;rsquo;s the thing users actually touch, and yet it hasn&amp;rsquo;t got a home.&lt;/p&gt;
&lt;h2 id="the-manifest-gives-it-one"&gt;The manifest gives it one
&lt;/h2&gt;&lt;p&gt;go-tool-base&amp;rsquo;s generator gives that structure a home: &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/generator/manifest.go#L54" target="_blank" rel="noopener"
 &gt;&lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;&lt;/a&gt;. The manifest is a single readable file describing the command tree. Every command, its name, its short description, its flags, its place in the hierarchy, whether it carries assets or an initialiser. The shape of the whole tool, in one place you can open and read top to bottom.&lt;/p&gt;
&lt;p&gt;And the manifest isn&amp;rsquo;t documentation &lt;em&gt;about&lt;/em&gt; the project. It&amp;rsquo;s the thing the project&amp;rsquo;s wiring is generated &lt;em&gt;from&lt;/em&gt;. When you run &lt;code&gt;regenerate project&lt;/code&gt;, the generator reads the manifest and rebuilds the boilerplate to match it: the command registration, the &lt;code&gt;AddCommand&lt;/code&gt; wiring, the flag definitions. The manifest is the source of truth, and the Go wiring is its output.&lt;/p&gt;
&lt;h2 id="design-first-when-you-want-it"&gt;Design-first, when you want it
&lt;/h2&gt;&lt;p&gt;This unlocks a way of working that the smeared-across-the-codebase approach simply can&amp;rsquo;t offer. You can design the interface first, in the manifest, and let the code follow.&lt;/p&gt;
&lt;p&gt;Want to rename a command? Edit one line in the manifest, run &lt;code&gt;regenerate&lt;/code&gt;, and the rename propagates through every wiring file that ever mentioned it. Want to move a subcommand under a different parent? Change its place in the manifest hierarchy and regenerate. Want to add a flag to three related commands? Add it in the manifest, in three obvious places, and regenerate, instead of going on a little hunting expedition for three flag-registration blocks scattered across the tree.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;re editing the tool&amp;rsquo;s interface as a design, in the file whose entire job is to hold that design, and the generator does the mechanical donkey-work of making the code reflect it. The thing you change is the thing that describes the structure. The code is downstream.&lt;/p&gt;
&lt;p&gt;If that shape sounds familiar, it should. It&amp;rsquo;s the same instinct behind spec-driven and test-driven development: write down what the thing should &lt;em&gt;be&lt;/em&gt; before you assemble how it works, and keep that statement of intent as a first-class, living artefact rather than a comment that quietly rots in a corner. The manifest is a spec for your command tree, and &lt;code&gt;regenerate&lt;/code&gt; is what keeps the implementation honest to it.&lt;/p&gt;
&lt;h2 id="it-doesnt-trap-you"&gt;It doesn&amp;rsquo;t trap you
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s an obvious worry about any generated-from-a-manifest system: am I now locked into editing the manifest? What if I just want to open a Go file and write some Go like a normal person?&lt;/p&gt;
&lt;p&gt;You can. The generator is careful not to own everything. It owns the wiring (the registration and the structural boilerplate) and it leaves your command logic well alone. The &lt;code&gt;RunE&lt;/code&gt; function where your command actually does its work is yours; the manifest hasn&amp;rsquo;t got an opinion about it. And the generator tracks the files it produces by content hash, so if you do hand-edit something it generated, regeneration notices and asks before overwriting rather than steamrolling you. That mechanism turned out interesting enough to get &lt;a class="link" href="https://phpboyscout.uk/scaffolding-that-respects-your-edits/" &gt;its own post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So the manifest is an option, not a cage. Design-first via the manifest when that suits the change. Drop into Go directly when that suits it better. The two stay in sync because regeneration reconciles them, not because one of them has been forbidden.&lt;/p&gt;
&lt;h2 id="pulling-it-together"&gt;Pulling it together
&lt;/h2&gt;&lt;p&gt;A CLI&amp;rsquo;s command tree is its most important design surface, and in most projects it has no single home&amp;hellip; it gets reconstructed in your head from twenty scattered files every time you need to reason about it. go-tool-base gives it one: &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;, a readable description of the whole tree that the generator rebuilds the wiring code from. Edit the manifest, run &lt;code&gt;regenerate&lt;/code&gt;, and the boilerplate follows.&lt;/p&gt;
&lt;p&gt;It makes CLI structure something you design in one place, in the spirit of spec-driven development, while still leaving you free to write Go directly when that&amp;rsquo;s the better tool for the job. The manifest is the spec for your interface. The generator just keeps the code faithful to it.&lt;/p&gt;</description></item><item><title>Scaffolding that respects your edits</title><link>https://phpboyscout.uk/scaffolding-that-respects-your-edits/</link><pubDate>Fri, 20 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/scaffolding-that-respects-your-edits/</guid><description>&lt;img src="https://phpboyscout.uk/scaffolding-that-respects-your-edits/cover-scaffolding-that-respects-your-edits.png" alt="Featured image of post Scaffolding that respects your edits" /&gt;&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;introduced go-tool-base&lt;/a&gt; I made a passing promise to come back to &amp;ldquo;the generator that won&amp;rsquo;t clobber your edits&amp;rdquo;. This is me keeping it, partly because it&amp;rsquo;s the feature I&amp;rsquo;m quietly most proud of, and partly because it took the most head-scratching of anything to get right.&lt;/p&gt;
&lt;p&gt;The problem it solves is one that every code generator runs into eventually, usually the hard way and usually at the worst possible moment.&lt;/p&gt;
&lt;h2 id="the-generators-awkward-second-act"&gt;The generator&amp;rsquo;s awkward second act
&lt;/h2&gt;&lt;p&gt;A project generator has an easy first act. &lt;code&gt;gtb generate project&lt;/code&gt;, and you&amp;rsquo;ve got a complete, wired, idiomatic Go CLI project. Everyone&amp;rsquo;s happy, me included.&lt;/p&gt;
&lt;p&gt;The second act is the hard one. The framework moves on. A convention changes, a new built-in capability appears, the recommended CI shape shifts. Your project, scaffolded three months ago, is now subtly out of date, and you&amp;rsquo;d quite like the generator to drag it back up to spec.&lt;/p&gt;
&lt;p&gt;Except by now it isn&amp;rsquo;t a fresh scaffold. It&amp;rsquo;s &lt;em&gt;your&lt;/em&gt; project. You tuned the CI workflow. You rewrote the &lt;code&gt;justfile&lt;/code&gt;. You added a stanza to the Dockerfile that took an afternoon and a fair bit of swearing to get right. The generated files and your edited files are one and the same files.&lt;/p&gt;
&lt;p&gt;A naive generator handles this with breathtaking confidence: it regenerates everything from the template and overwrites the lot. Run it once, lose your afternoon. You learn that lesson exactly once and then never run regeneration again, which means the upkeep feature you were sold is dead on arrival. A scaffold you can&amp;rsquo;t safely re-run is just a one-shot &lt;code&gt;cp&lt;/code&gt; with extra steps.&lt;/p&gt;
&lt;h2 id="what-the-generator-needs-to-know"&gt;What the generator needs to know
&lt;/h2&gt;&lt;p&gt;The thing standing between &amp;ldquo;safe to overwrite&amp;rdquo; and &amp;ldquo;absolutely do not&amp;rdquo; is a single fact: has this file changed since the generator last wrote it?&lt;/p&gt;
&lt;p&gt;If it hasn&amp;rsquo;t, the file is still pristine boilerplate and the generator owns it. Overwrite away. If it has, a human has been in there, and the generator must not touch it without asking first.&lt;/p&gt;
&lt;p&gt;The generator can&amp;rsquo;t just eyeball that, of course. It needs a record. So every time &lt;code&gt;gtb generate&lt;/code&gt; writes a file, it computes a SHA-256 of the content and stores it in the project&amp;rsquo;s manifest, &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;, as a &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/generator/manifest.go#L54" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Hashes&lt;/code&gt; map&lt;/a&gt; of relative path to hash. The manifest is the generator&amp;rsquo;s memory of the exact bytes it last produced.&lt;/p&gt;
&lt;h2 id="regeneration-becomes-a-three-way-decision"&gt;Regeneration becomes a three-way decision
&lt;/h2&gt;&lt;p&gt;With that record in hand, regeneration stops being &amp;ldquo;overwrite everything&amp;rdquo; and becomes a per-file decision with three branches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The file doesn&amp;rsquo;t exist.&lt;/strong&gt; Easy. Write it, store its hash.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The file exists and its current hash matches the manifest.&lt;/strong&gt; It&amp;rsquo;s byte-for-byte what the generator last wrote, so nobody has touched it. The generator owns it outright, regenerates from the template and updates the stored hash. No prompt, no fuss. This is the common case, and it&amp;rsquo;s silent precisely because it&amp;rsquo;s safe.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The file exists and its hash does &lt;em&gt;not&lt;/em&gt; match.&lt;/strong&gt; Someone has been in there since generation. The generator stops and asks. It will not silently overwrite your hard-won afternoon. You decide: take the new version, or keep yours.&lt;/p&gt;
&lt;p&gt;The detail I&amp;rsquo;m genuinely fond of is what happens when you decline. Declining is non-fatal. Generation carries on with the rest of the files, and the manifest &lt;em&gt;keeps&lt;/em&gt; the file&amp;rsquo;s stored hash rather than dropping it. That matters more than it looks, because it means the file stays tracked. Next time you regenerate, the generator can still tell that file has been modified, and still asks. Skipping a file once doesn&amp;rsquo;t quietly evict it from the generator&amp;rsquo;s awareness forever. It stays a known, watched, customised file across every future run.&lt;/p&gt;
&lt;h2 id="when-you-want-it-to-stop-asking"&gt;When you want it to stop asking
&lt;/h2&gt;&lt;p&gt;Per-file prompting is the right default, but for files you&amp;rsquo;ve &lt;em&gt;permanently&lt;/em&gt; taken ownership of, being asked on every single regeneration is just noise. If you&amp;rsquo;ve rewritten the CI workflows wholesale and you are never, ever going back to the generated version, you don&amp;rsquo;t want a prompt. You want the generator to leave them well alone and not bring it up again.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/internal/generator/ignore.go" target="_blank" rel="noopener"
 &gt;&lt;code&gt;.gtb/ignore&lt;/code&gt;&lt;/a&gt; is for. It sits next to the manifest and takes gitignore-style patterns:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# I own the CI workflows now
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;.github/workflows/**
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# ...except the release workflow, keep that managed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;!.github/workflows/release.yml
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;# and my build config
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;justfile
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Dockerfile
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Anything matching is skipped during regeneration with no prompt at all. Patterns evaluate top to bottom and later ones win, so the negation (&lt;code&gt;!&lt;/code&gt;) behaves the way you&amp;rsquo;d expect from &lt;code&gt;.gitignore&lt;/code&gt;: exclude a whole directory, then claw one file back.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a deliberate escalation ladder. Unmodified files are handled silently. Modified files get a prompt. Files you&amp;rsquo;ve formally claimed get total silence. Each rung asks for less of your attention than the last, and you choose how far up to climb, file by file.&lt;/p&gt;
&lt;h2 id="stepping-back"&gt;Stepping back
&lt;/h2&gt;&lt;p&gt;A generator earns its keep twice: once when it scaffolds your project, and then continuously, every time it drags that project back up to the framework&amp;rsquo;s current shape. The second job is worth nothing if regeneration flattens your customisations, because you&amp;rsquo;ll simply stop running it, and who could blame you.&lt;/p&gt;
&lt;p&gt;go-tool-base&amp;rsquo;s generator gets around that by remembering. It hashes every file it writes into &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt;, and on regeneration it re-hashes before overwriting: unchanged files it owns and updates silently, changed files it stops and asks about, and &lt;code&gt;.gtb/ignore&lt;/code&gt; lets you mark files as permanently yours. Skipped files stay tracked, so the generator never loses sight of what you&amp;rsquo;ve made your own.&lt;/p&gt;
&lt;p&gt;The point of a scaffold isn&amp;rsquo;t the first five minutes. It&amp;rsquo;s that you can still run it in month three without holding your breath.&lt;/p&gt;</description></item><item><title>Your CLI is already an AI tool</title><link>https://phpboyscout.uk/your-cli-is-already-an-ai-tool/</link><pubDate>Thu, 19 Mar 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/your-cli-is-already-an-ai-tool/</guid><description>&lt;img src="https://phpboyscout.uk/your-cli-is-already-an-ai-tool/cover-your-cli-is-already-an-ai-tool.png" alt="Featured image of post Your CLI is already an AI tool" /&gt;&lt;p&gt;&amp;ldquo;Make it work with AI&amp;rdquo; has become one of those requests that lands on a developer&amp;rsquo;s desk with a thud and not much further detail attached. My instinct, the first time, was to brace for a big lump of integration work&amp;hellip; a bespoke adapter for this assistant, another for that one, a treadmill of little wrappers stretching off into the distance.&lt;/p&gt;
&lt;p&gt;Turns out I&amp;rsquo;d already done most of the work. So have you, if your CLI tool is any good. Let me explain what I mean.&lt;/p&gt;
&lt;h2 id="you-already-described-your-capabilities"&gt;You already described your capabilities
&lt;/h2&gt;&lt;p&gt;Stop and think for a second about what a well-built CLI tool actually is. It&amp;rsquo;s a set of named operations, each with a human-readable description, each taking a set of typed, named, documented parameters. You wrote all of that already, because a CLI without it is unusable by &lt;em&gt;people&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Now look at what an AI assistant needs in order to call a tool. A set of named operations. A description of each, so it knows when to reach for them. A typed parameter schema for each, so it knows how to call them.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s the same list! A good CLI is already, structurally, a description of a set of capabilities. The information an AI agent needs isn&amp;rsquo;t extra work you have to go and do. It&amp;rsquo;s work you finished the moment your &lt;code&gt;--help&lt;/code&gt; output was any good.&lt;/p&gt;
&lt;p&gt;The only thing missing is a translator. Something that takes &amp;ldquo;this is a CLI&amp;rdquo; and presents it as &amp;ldquo;this is a set of tools an AI can call&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="mcp-is-that-translator-and-its-a-standard"&gt;MCP is that translator, and it&amp;rsquo;s a standard
&lt;/h2&gt;&lt;p&gt;The temptation, when you want your tool to be AI-usable, is to sit down and write an integration. A little adapter for Claude Desktop. Another for Cursor. Another for whatever turns up next month. Each one a bespoke wrapper, each one a thing to maintain, and the list never stops growing because new assistants keep appearing. That&amp;rsquo;s the treadmill I was bracing for.&lt;/p&gt;
&lt;p&gt;The Model Context Protocol exists to kill that list. MCP is an open standard for how an AI model discovers and calls local tools. Implement it once and your tool works with every assistant that speaks it. Write once, not once-per-client.&lt;/p&gt;
&lt;p&gt;So go-tool-base implements it once, in the framework, for everyone. (That&amp;rsquo;s rather the theme of this whole series, if you hadn&amp;rsquo;t spotted it yet&amp;hellip; do the annoying thing once, properly, in a place where every tool inherits it.)&lt;/p&gt;
&lt;h2 id="the-mcp-command-and-the-mapping-it-does-for-free"&gt;The &lt;code&gt;mcp&lt;/code&gt; command, and the mapping it does for free
&lt;/h2&gt;&lt;p&gt;Every tool built on go-tool-base inherits a built-in &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/props/tool.go#L15" target="_blank" rel="noopener"
 &gt;&lt;code&gt;mcp&lt;/code&gt; command&lt;/a&gt;. Run it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;mytool mcp
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;and the tool starts a JSON-RPC server over standard I/O, speaking MCP. That&amp;rsquo;s the whole user-facing surface. One command.&lt;/p&gt;
&lt;p&gt;Behind it, the framework walks your Cobra command tree and maps it straight onto MCP tool definitions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each &lt;strong&gt;command&lt;/strong&gt; becomes a &lt;strong&gt;tool&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Each command&amp;rsquo;s &lt;strong&gt;short description&lt;/strong&gt; becomes the &lt;strong&gt;tool&amp;rsquo;s description&lt;/strong&gt;, the text the AI reads to decide whether this is the tool it wants.&lt;/li&gt;
&lt;li&gt;Each command&amp;rsquo;s &lt;strong&gt;flags and arguments&lt;/strong&gt; become the tool&amp;rsquo;s &lt;strong&gt;JSON Schema parameters&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There&amp;rsquo;s no second schema to write and then keep in sync (and we all know how well &amp;ldquo;keep these two things aligned by hand&amp;rdquo; tends to go). The command tree &lt;em&gt;is&lt;/em&gt; the schema. Add a new command to your CLI and it&amp;rsquo;s a new tool for the agent, automatically, with the description and flags you already gave it. Nobody has to remember to update an MCP manifest, because there&amp;rsquo;s no separate MCP manifest to forget about.&lt;/p&gt;
&lt;h2 id="configuring-an-assistant-to-use-it"&gt;Configuring an assistant to use it
&lt;/h2&gt;&lt;p&gt;On the assistant&amp;rsquo;s side it&amp;rsquo;s just as undramatic. You tell your AI client (Claude Desktop, Cursor, anything MCP-aware) to launch &lt;code&gt;mytool mcp&lt;/code&gt;. From then on the assistant:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Starts your tool in MCP mode when it boots.&lt;/li&gt;
&lt;li&gt;Discovers every command as a callable tool.&lt;/li&gt;
&lt;li&gt;Calls the right one, with the right parameters, when a user&amp;rsquo;s request needs it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Your CLI tool has quietly become something the AI can pick up and use, mid-conversation, on its own initiative.&lt;/p&gt;
&lt;h2 id="the-safety-property-worth-noticing"&gt;The safety property worth noticing
&lt;/h2&gt;&lt;p&gt;Now, &amp;ldquo;let an AI run things on my machine&amp;rdquo; is rightly a sentence that makes people nervous. It makes me nervous, and I built the thing. So it&amp;rsquo;s worth noticing the constraint sitting quietly in this design.&lt;/p&gt;
&lt;p&gt;The AI can only call what you defined. The tools it sees are exactly the commands in your tree, and the parameters it can pass are exactly the flags and arguments you declared, validated against the JSON Schema generated from them.&lt;/p&gt;
&lt;p&gt;It can&amp;rsquo;t invent a command. It can&amp;rsquo;t pass a parameter you never defined. The boundary of what the agent can do is the boundary of what your CLI does, and you drew that boundary already, back when you built the tool. Exposing the CLI over MCP doesn&amp;rsquo;t widen the surface one inch. It just makes the existing surface reachable. The AI isn&amp;rsquo;t running &lt;em&gt;things&lt;/em&gt;. It&amp;rsquo;s running &lt;em&gt;your commands&lt;/em&gt;, the ones you wrote, tested and shipped, and nothing else.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;A CLI tool, built properly, is already a structured description of a set of capabilities: named operations, descriptions, typed parameters. Which is also exactly what an AI agent needs in order to call a tool. The gap between the two is only a translator, and writing a bespoke one per assistant is a treadmill you don&amp;rsquo;t need to step onto.&lt;/p&gt;
&lt;p&gt;go-tool-base puts the translator in the framework. Every tool gets an &lt;code&gt;mcp&lt;/code&gt; command that serves the command tree over the Model Context Protocol&amp;hellip; commands become tools, descriptions become descriptions, flags become JSON Schema parameters, with no second schema to maintain. Point any MCP-aware assistant at it and your CLI is an agent-callable tool, bounded to exactly the commands you shipped.&lt;/p&gt;
&lt;p&gt;You did the hard part when you built a good CLI. MCP just opens the door you&amp;rsquo;d already framed.&lt;/p&gt;</description></item><item><title>Pre-populating Neo4J using Kubernetes Init Containers and neo4j-admin import</title><link>https://phpboyscout.uk/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/</link><pubDate>Wed, 15 Jul 2020 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/</guid><description>&lt;img src="https://phpboyscout.uk/pre-populating-neo4j-using-kubernetes-init-containers-and-neo4j-admin-import/maxresdefault.jpg" alt="Featured image of post Pre-populating Neo4J using Kubernetes Init Containers and neo4j-admin import" /&gt;&lt;p&gt;Recently there has been an uptake in the use of Neo4j by the Data Scientists. This is a good thing! they are wanting to use the right tool for the job. However we need to run it inside our k8s cluster as a portable readable data source that has been dynamically populated from a pile of data in a combination of PostgreSQL and MongoDB.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t a problem for them working locally, they install and spin up a local copy of Neo4j and can interact with it quite happily. They even realised that they can generate CSV&amp;rsquo;s from PostgreSQL and MongoDB and then import them, blindingly fast, into Neo4j using the &lt;code&gt;neo4j-admin&lt;/code&gt; tool that comes bundled. Fantastic!&lt;/p&gt;
&lt;p&gt;At least until they come to want to run their Neo instance inside our k8s cluster. That&amp;rsquo;s where I step in and turn them aside from creating their own custom neo4j image with a bespoke entry point that loads all the data for them in some crazy threaded bash scripting!&lt;/p&gt;
&lt;p&gt;&amp;ldquo;No, No, No!&amp;rdquo; I tell them. &amp;ldquo;It&amp;rsquo;s far easier to just add an init container to your pod, that will preload the data before Neo starts up&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Init containers, if you haven&amp;rsquo;t come across before, them are a special type of container that lives inside a k8s pod and are set to run &lt;strong&gt;BEFORE&lt;/strong&gt; your main container runs. In this case it means we can easily sequence a bash script to run the &lt;code&gt;neo4j-admin import&lt;/code&gt; before Neo4j is even started. And here is how we did it!&lt;/p&gt;
&lt;h2 id="the-script"&gt;The script
&lt;/h2&gt;&lt;p&gt;The data scientists had been using Neo4j 3.5.x locally because they had a need for the graph algorithms plugin (&lt;a class="link" href="https://github.com/neo4j-contrib/neo4j-graph-algorithms" target="_blank" rel="noopener"
 &gt;https://github.com/neo4j-contrib/neo4j-graph-algorithms&lt;/a&gt;) which at the time they were looking didn&amp;rsquo;t support Neo4j 4.x. The plugin is now deprecated and its replacement (&lt;a class="link" href="https://github.com/neo4j/graph-data-science" target="_blank" rel="noopener"
 &gt;https://github.com/neo4j/graph-data-science&lt;/a&gt;) thankfully supports 3.5.x and 4.x.&lt;/p&gt;
&lt;p&gt;As Neo4j 4.x introduces a lot of new features and improves performance so I recommended we switch to using that. This meant a refactor of their bash script for neo4j-admin there some very subtle differences and a few caveats to work with. This is what they came up with&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="cp"&gt;#!/bin/bash
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;DBNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;neo4j&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$#&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; -eq &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;DBNAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# extract data from SQL&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;python3 extract_data.py
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# remove old db for rebuild&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf &lt;span class="s2"&gt;&amp;#34;/data/databases/&lt;/span&gt;&lt;span class="nv"&gt;$DNBAME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;neo4j-admin import &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --database&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$DBNAME&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --delimiter&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;|&amp;#34;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;Protein&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_protein_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_proteins.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;UniProtKB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_uniprot_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/nodes_uniprot.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;HAS_AMINO_ACID_SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EDGE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_protein_sequence_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_protein_sequence.csv &lt;span class="se"&gt;\ &lt;/span&gt; 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;HAS_AMINO_ACID_SEQUENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;EDGE_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_chembl_protein_biotherapeutic_molregno_header.csv,&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DATA_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;/edges_chembl_protein_biotherapeutic_molregno.csv &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --skip-bad-relationships&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --skip-duplicate-nodes&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;import&lt;/code&gt; command here is significantly shorter for example purposes, as the original is about 120 lines long. As you can see it&amp;rsquo;s pretty straight forward, they had another script in &lt;code&gt;extract_data.py&lt;/code&gt;, that I wont bore you with suffice to say that it pulled out all the data they wanted from PostgreSQL and MongoDB, which got saved to disk as CSV files in the relevant directories.&lt;/p&gt;
&lt;p&gt;Great, it worked on their local version!&lt;/p&gt;
&lt;h2 id="the-dockerfile"&gt;The Dockerfile
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ROM neo4j:latest
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4JLABS_PLUGINS [&amp;#34;graph-data-science&amp;#34;]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;RUN apt update &amp;amp;&amp;amp; apt install -y python3
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;WORKDIR /srv
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;COPY src /srv/src
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;COPY headers /srv/headers
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The plan is always to keep it simple. We have one image that we can run for both the init container and the main container. This docker file gives a vanilla neo4j instance with python and our scripts for extracting the data loaded into it&lt;/p&gt;
&lt;h2 id="the-k8s-manifest"&gt;The k8s Manifest
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;apiVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;v1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Pod&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;NEO4J_AUTH&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j/password&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;registry.example.com/phpboyscout/rnd_graph:latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Always&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumeMounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;initContainers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;importer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;neo4j_import.sh&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;/bin/bash&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;DATA_DIR&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/import/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;HEADER_DIR&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/srv/headers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;registry.example.com/phpboyscout/rnd_graph:latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;imagePullPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Always&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;workingDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/srv/src&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumeMounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;data&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;mountPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/import&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;subPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;import&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;claimName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;neo4j&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now we can pull it all together with our k8s manifest. From here you can see that we have our default neo4j container that we pass in our default authentication details to and an init container that runs our &lt;code&gt;import.sh&lt;/code&gt; script. Both containers have access to a shared volume for the &lt;code&gt;/import&lt;/code&gt; and &lt;code&gt;/data&lt;/code&gt; folders.&lt;/p&gt;
&lt;p&gt;And now we get to&amp;hellip;&lt;/p&gt;
&lt;h2 id="troubleshooting"&gt;Troubleshooting
&lt;/h2&gt;&lt;p&gt;So right off the bat it didn&amp;rsquo;t work! No surprises there but here are a few things that caused us some issues and how we resolved them.&lt;/p&gt;
&lt;h3 id="database-offline"&gt;Database offline
&lt;/h3&gt;&lt;p&gt;At first glance everything seemed to work. Until we tried to connect to the &lt;code&gt;neo4j&lt;/code&gt; database with the default UI, at which point we were presented with the error message&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Database &amp;#34;neo4j&amp;#34; is unavailable, its status is &amp;#34;offline.&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This took a little sleuthing and shelling into the neo4j container to take a look at the &lt;code&gt;/var/debug.log&lt;/code&gt; file which gives significantly more useful information about whats going on with the server. First we were getting stack traces that contained messages like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Component &amp;#39;org.neo4j.kernel.impl.transaction.log.files.TransactionLogFiles@59d6a4d1&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;was successfully initialized, but failed to start. Please see the attached cause 
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;exception &amp;#34;/data/transactions/neo4j/neostore.transaction.db.0&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;From experience this sounded like a permissions issue and lo and behold, checking the files on the filesystem showed that because the import script was run as root the database files were owned by root. We resolved this by adding:-&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;chown -R neo4j:neo4j /data/
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;to the bottom of the import script. Next we were then presented with an error that looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2020-07-14 16:56:33.919+0000 WARN [o.n.k.d.Database] [neo4j] Exception occurred while
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;starting the database. Trying to stop already started components. Mismatching store id.
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This one seems like it would be an obvious one to google and I did come up with few pages that seemed to describe what was happening to me but gave some varied solutions, from starting and stopping the sever and running &lt;code&gt;neo4j-admin unbind&lt;/code&gt; in between to deleting various files. It seemed very strange because we did test this with the 3.5.17 version of Neo and it worked fine.&lt;/p&gt;
&lt;p&gt;The solution we needed was to wipe the slate clean properly. The line in our script to remove the previous build of the db&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# remove old db for rebuild&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf &lt;span class="s2"&gt;&amp;#34;/data/databases/&lt;/span&gt;&lt;span class="nv"&gt;$DNBAME&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;just didn&amp;rsquo;t cut it. It turns out that because the 4.x version of Neo4j supports multiple databases the &lt;code&gt;import&lt;/code&gt; command writes additional information to the system database and transactions database in the form of some identifiers for each database, BUT if you don&amp;rsquo;t do something to clear that value for the database your are building it wont match up when the server starts and you get a declaration of &lt;code&gt;Mismatching store id&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not sure if the developers are aware of this flaw, so in the mean time we have to expand our cleanup to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# clean up for fresh import&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf /data/databases/*
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rm -rf /data/transactions/*
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;removing the neoj4, system and store_lock databases and transaction logs from the data store. This solved the problem and the server was able to start and we could connect to neo4j database successful.&lt;/p&gt;
&lt;p&gt;Its not an ideal solution, I can foresee definite situations we will have to work around when we get to a point where multiple databases may be needed and are built separately and independently from each other. but it will suffice for now.&lt;/p&gt;
&lt;h3 id="malloc-error-message-goes-here"&gt;Malloc(): Error message goes here
&lt;/h3&gt;&lt;p&gt;Once it was up and running we noticed that we were getting lots of restarts on the main neo4j container a quick look at the &lt;code&gt;stdout&lt;/code&gt; log and we could see each restart ending with something that looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;malloc(): corrupted top size
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;instantly this looks like an issue with memory sizing inside the container for the JVM. Thankfully the team at Neo4j have accounted for this and give you a nice tool in the form of&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;neo4j-admin memrec
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;which interrogates the databases and gives some sensible values you can set in the output which in our case looked like&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Memory settings recommendation from neo4j-admin memrec:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Assuming the system is dedicated to running Neo4j and has 376.6GiB of memory,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# we recommend a heap size of around 31g, and a page cache of around 331500m,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# and that about 22400m is left for the operating system, and the native memory&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# needed by Lucene and Netty.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: If the indexing storage use is high, e.g. there are many indexes or most&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# data indexed, then it might advantageous to leave more memory for the&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# operating system.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: Depending on the workload type you may want to increase the amount&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# of off-heap memory available for storing transaction state.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# For instance, in case of large write-intensive transactions&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# increasing it can lower GC overhead and thus improve performance.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# On the other hand, if vast majority of transactions are small or read-only&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# then you can decrease it and increase page cache instead.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: The more concurrent transactions your workload has and the more updates&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# they do, the more heap memory you will need. However, don&amp;#39;t allocate more&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# than 31g of heap, since this will disable pointer compression, also known as&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# &amp;#34;compressed oops&amp;#34;, in the JVM and make less effective use of the heap.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tip: Setting the initial and the max heap size to the same value means the&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# JVM will never need to change the heap size. Changing the heap size otherwise&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# involves a full GC, which is desirable to avoid.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Based on the above, the following memory settings are recommended:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initial_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;dbms&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;memory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pagecache&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;331500&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# It is also recommended turning out-of-memory errors into full crashes,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# instead of allowing a partially crashed database to continue running:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#dbms.jvm.additional=-XX:+ExitOnOutOfMemoryError&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# The numbers below have been derived based on your current databases located at: &amp;#39;/var/lib/neo4j/data/databases&amp;#39;.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# They can be used as an input into more detailed memory analysis.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Total size of lucene indexes in all databases: 0k&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Total size of data and native indexes in all databases: 17300m&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So how to get these values into the container&amp;hellip; Thankfully this is handled for you in the form of Environment Variables you can pass into the docker image. A bit of a google and i found this little snippet which is a goldmine for telling us how to translate settings into environment variables.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Env variable naming convention:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - prefix NEO4J_&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - double underscore char &amp;#39;__&amp;#39; instead of single underscore &amp;#39;_&amp;#39; char in the setting name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# - underscore char &amp;#39;_&amp;#39; instead of dot &amp;#39;.&amp;#39; char in the setting name&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Example:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# NEO4J_dbms_tx__log_rotation_retention__policy env variable to set&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# dbms.tx_log.rotation.retention_policy setting&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As for getting the variables into the container, you could do this from the pod and inject it in. I this case because the data we are going to be using is reasonably stable and tested we decided to stick them into the Docker file with the &lt;code&gt;ENV&lt;/code&gt; directive.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_heap_initial__size 31g
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_heap_max__size 31g
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ENV NEO4J_dbms_memory_pagecache_size 331500m
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And so far we haven&amp;rsquo;t had a restart yet!&lt;/p&gt;</description></item></channel></rss>