<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Matt Cockayne</title>
    <description>The latest articles on DEV Community by Matt Cockayne (@phpboyscout).</description>
    <link>https://dev.to/phpboyscout</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F284848%2Feccd93ba-1bff-4e17-85cb-ff4c4bac6c6d.png</url>
      <title>DEV Community: Matt Cockayne</title>
      <link>https://dev.to/phpboyscout</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/phpboyscout"/>
    <language>en</language>
    <item>
      <title>Three models vote on what happened</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:17:11 +0000</pubDate>
      <link>https://dev.to/phpboyscout/three-models-vote-on-what-happened-4nkp</link>
      <guid>https://dev.to/phpboyscout/three-models-vote-on-what-happened-4nkp</guid>
      <description>&lt;p&gt;I ran the same job past three different AI providers and had them vote on the answer, and I did it for a hobby project about a Sunday night D&amp;amp;D game. That sounds a tad extravagant, I know, right up until you look at what the job actually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the job actually is
&lt;/h2&gt;

&lt;p&gt;A tabletop campaign runs for years. Mine's been going a while and meets most weeks, and in that time the party has met hundreds of people, made dozens of promises, and learned things they've long since forgotten they know. Very little of it gets written down properly (I speak as the DM who was supposed to be writing it down).&lt;/p&gt;

&lt;p&gt;The Dungeon Master invents a barkeep on the spot, gives him a name, and six weeks later the party walks back through that door expecting him to still exist. &lt;a href="https://phpboyscout.uk/the-barkeep-i-hadnt-invented-yet/" rel="noopener noreferrer"&gt;I've written about that particular horror before&lt;/a&gt;. &lt;a href="https://scoutdm.com" rel="noopener noreferrer"&gt;Scout&lt;/a&gt; listens to the session and turns it into structured campaign facts. Who was in the room, who said what, what got promised, what the party now knows that it didn't at teatime.&lt;/p&gt;

&lt;p&gt;What I was slow to appreciate is that every proper noun in that transcript is invented. Not obscure or niche, invented that week by one of us sat at the table, and meaningless everywhere else in the universe.&lt;/p&gt;

&lt;p&gt;A model pulling facts out of a news article can lean on knowing what Paris is. A model pulling facts out of my Sunday night has no idea whether &lt;em&gt;candlefoot&lt;/em&gt; is a place, a person, an illness or a poor life choice, because until three weeks ago it wasn't anything at all. There's no world knowledge to fall back on, only what was said and how carefully you listened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extraction is a write
&lt;/h2&gt;

&lt;p&gt;That sounds like transcription, but it isn't quite. Transcription is a &lt;em&gt;read&lt;/em&gt;, and getting it wrong produces one duff sentence that a reader notices and ignores. Extraction is a &lt;em&gt;write&lt;/em&gt;: the facts go into a canonical store, and every answer Scout gives afterwards is drawn from that store.&lt;/p&gt;

&lt;p&gt;So a wrong fact doesn't produce a bad answer once. It sits there being wrong, poisoning everything downstream of it, and a confidently recorded fact looks no different from a correct one. Six months later a player asks who the party bought the horses from, Scout tells them, and it's wrong, and there's no way to tell because the answer arrives as confidently as all the right ones. That asymmetry is most of the argument for me: where a bad write is permanent and a duplicate read costs pennies, redundancy is the cheap side of the trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why three models rather than one better prompt
&lt;/h2&gt;

&lt;p&gt;The obvious objection is that this is a prompting problem. Write a better prompt, pick the best model, spend the money once. I don't think that holds, and the reason is what the three of them actually did. They didn't fail &lt;em&gt;less&lt;/em&gt; or &lt;em&gt;more&lt;/em&gt; than each other, they failed in different directions. Gemini in particular kept reaching for the source material instead of the transcript in front of it, which is behaviour I'd already met in NotebookLM and had to prompt my way around there too.&lt;/p&gt;

&lt;p&gt;That's what a panel is for. Three copies of one model at temperature zero don't give you a panel, they give you the same answer three times with a rosette on it! The redundancy only buys anything if the members are wrong about &lt;em&gt;different&lt;/em&gt; things, and providers trained differently on different data are about the best independent-ish failures you'll get without building your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The voting was the easy part
&lt;/h2&gt;

&lt;p&gt;Here's where I lost a weekend. Majority voting over a set of facts assumes you can tell when two facts are the same fact, and that is a much harder question than it sounds. I ended up refereeing cases by hand just to find out how hard, and here's a real one: two models extracted the same thirty seconds of play, and both produced a statement about it.&lt;/p&gt;

&lt;p&gt;The invented names won't mean anything to you, and that's rather the point. Read them as three opaque tokens that happen to be a place, a substance and an event.&lt;/p&gt;

&lt;p&gt;Both statements contained the same trio: &lt;em&gt;kettlesteam&lt;/em&gt;, &lt;em&gt;candlefoot&lt;/em&gt; and &lt;em&gt;the stolen voice&lt;/em&gt;. Same three things, same relationship between them, unambiguously the same moment. A hard match, no argument... except each model had also bundled &lt;em&gt;other&lt;/em&gt; facts into the same statement, and those didn't correspond at all. One had folded in who was present. The other had folded in what it cost.&lt;/p&gt;

&lt;p&gt;So the two agree to a high degree of probability, are not identical, and are really three separate facts that happen to share a sentence. Then a second case, where one model named a thing outright and the other only implied it. &lt;em&gt;Jingle Jangle&lt;/em&gt; is inferred rather than named, which is still true so long as the inference is correct, and there's nothing in the transcript that lets a vote-counter decide whether it was.&lt;/p&gt;

&lt;p&gt;So do those agree? I don't think there's a clean answer, because the question is wrong. "Do these two agree" is really a question about how big a fact is, and until you've settled that, a majority vote has nothing well-defined to count. Three models can produce three statements describing the same events and still cast three different votes, purely because they chopped those events up differently. The voting mechanism is about forty lines. The fact-granularity problem is the actual project (I did say I lost a weekend).&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill, which is not a footnote
&lt;/h2&gt;

&lt;p&gt;Three inferences where a normal pipeline has one, on every session, forever. For a product whose viability rests on unit cost, that's a design decision with an invoice stapled to it. Early passes went through the local Claude backend specifically to spend a subscription I'd already paid for instead of API credit, which is every bit as cheap as it sounds, and I'd recommend it to anyone prototyping (a subscription you've already paid for is the best price there is).&lt;/p&gt;

&lt;p&gt;Testing a panel to understand what it buys is fine. Shipping one before I know what it costs per session is not, and those are different decisions that are very easy to blur when the thing works nicely in a spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roster is not the design
&lt;/h2&gt;

&lt;p&gt;The three names are not the durable part of this. Anthropic, Gemini and OpenAI are the panel &lt;em&gt;today&lt;/em&gt;, and I'd be surprised if that survives the next eighteen months: providers change, prices change, models get deprecated with a fortnight's notice, and something will turn up that's better at this specific job than any of the three currently sat on it. I suspect none of that touches the design.&lt;/p&gt;

&lt;p&gt;What I'm testing isn't "are these the right three". It's whether a panel with a reconciliation step produces a more trustworthy write than a single call does, and whether the disagreements are informative enough to be worth paying for. If that holds, the roster is an implementation detail: swap a member, swap two, run five when the stakes are high and one when they aren't. The structure is the thing that has to be right, and the structure is what the experiment is actually interrogating. It's the same instinct as &lt;a href="https://phpboyscout.uk/the-packages-that-didnt-want-to-leave/" rel="noopener noreferrer"&gt;not building on one vendor's shape&lt;/a&gt;, designing for the role and not the incumbent.&lt;/p&gt;

&lt;h2&gt;
  
  
  It talks through my own modules
&lt;/h2&gt;

&lt;p&gt;All of that is a good deal easier because the panel reaches all three providers through the estate's own chat modules instead of raw HTTP calls: &lt;code&gt;go/chat&lt;/code&gt; with &lt;code&gt;chat-anthropic&lt;/code&gt;, &lt;code&gt;chat-gemini&lt;/code&gt; and &lt;code&gt;chat-openai&lt;/code&gt; underneath it, the shape &lt;a href="https://phpboyscout.uk/the-extraction-playbook-only-got-real-when-chat-left/" rel="noopener noreferrer"&gt;the extraction programme&lt;/a&gt; built them into.&lt;/p&gt;

&lt;p&gt;Three providers, one interface. Changing who's on the panel is a config change and a dependency, not a rewrite, and that's why the roster gets to be disposable. It also promptly exposed a rate-limiting gap in those modules, which got filed upstream against them (by me, against me) rather than patched around locally. That's the deal you make with dogfooding: the tool you built tells you about itself, in public, at the least convenient moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's got to
&lt;/h2&gt;

&lt;p&gt;Scout is still private and still very much unfinished, and the panel is an investigation rather than a shipped feature. What the investigation produced wasn't a voting mechanism so much as the discovery that I'd been asking the wrong question for nearly two weeks. I thought the problem was picking the right model... it was working out what a fact is.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/three-models-vote-on-what-happened/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 14 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>craft</category>
    </item>
    <item>
      <title>Which tests would you cut when the bill arrives?</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:36:41 +0000</pubDate>
      <link>https://dev.to/phpboyscout/which-tests-would-you-cut-when-the-bill-arrives-16h8</link>
      <guid>https://dev.to/phpboyscout/which-tests-would-you-cut-when-the-bill-arrives-16h8</guid>
      <description>&lt;p&gt;The bill for the runner fleet came to $270, which doesn't sound like a lot of money until it's money you haven't got.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://phpboyscout.uk/the-runner-fleet-that-scales-to-zero/" rel="noopener noreferrer"&gt;Yesterday's post&lt;/a&gt; was about building that fleet, the one that costs nothing when nobody's pushing. That was true! It's just that I'd been pushing quite a lot, and by the time the bill landed the fleet was already switched off. It's still off now, because money's tight, and the whole estate went back to the one homelab runner with its three slots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two easy answers
&lt;/h2&gt;

&lt;p&gt;When a bill like that arrives there are two obvious ways out, and I looked hard at both.&lt;/p&gt;

&lt;p&gt;The first is to spend more. GitLab Premium comes with merge trains and a big pile of extra compute minutes, and I spent a morning working out what it would really buy me (beyond a lighter wallet). I resisted the urge to open my wallet any further than I already had. It isn't off the table for good, but I'm doing my best to avoid it if I can.&lt;/p&gt;

&lt;p&gt;The second is to test less. Every pipeline is a stack of jobs, and some of them are big: mutation testing, fuzzing, cross-architecture builds. Pull a few of those off the merge requests, run them later on main or not at all, and the queue gets shorter overnight.&lt;/p&gt;

&lt;p&gt;That one I wasn't having. I would rather pay in waiting time than drop a single test. Quality beats quantity every time, so fast would be nice, but right matters more, and if the pipelines had to get cheaper they'd have to find it somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the receipt
&lt;/h2&gt;

&lt;p&gt;So rather than guess at what to cut, I went through the receipt line by line. Three weeks of merge-request pipelines, 3,373 of them, 31,247 jobs, every minute accounted for (it's all written up in &lt;a href="https://gitlab.com/phpboyscout/cicd/-/wikis/specs/0079-pipeline-churn-on-three-runner-slots" rel="noopener noreferrer"&gt;cicd's spec 0079&lt;/a&gt; if you fancy the full table).&lt;/p&gt;

&lt;p&gt;And my hunch about where the money went was wrong. The scary heavy jobs (mutation, fuzz, cross-arch) came to 5.7% of merge-request minutes, which is next to nothing. The ordinary Go gate, test and lint and the vulnerability check, was 40.6%, at about a minute a go, run two thousand-odd times, and that's a price I'm happy to pay on every push.&lt;/p&gt;

&lt;p&gt;The real cost was elsewhere. Renovate's branches were nearly half of all merge-request minutes, and the single biggest line on the whole receipt was Renovate bumping the version pins on the &lt;a href="https://cicd.phpboyscout.uk/" rel="noopener noreferrer"&gt;cicd&lt;/a&gt; components, at 23.4% on its own. Release merge requests were another 20.4%, and that one was entirely self-inflicted: I'd put &lt;code&gt;CHANGELOG.md&lt;/code&gt; into the test jobs' change rules back in July, so a release that touched nothing but the changelog woke the whole suite up to test a file of markdown!&lt;/p&gt;

&lt;p&gt;None of that is a test I'd miss. The component pins went (everything now tracks the components' main branch, so there's nothing to bump), and the changelog came out of the rules, because under &lt;a href="https://colophon.phpboyscout.uk/" rel="noopener noreferrer"&gt;colophon&lt;/a&gt; a release merge request is main's tree plus the files colophon writes, and every commit in main's tree has already been through a merge request that ran the lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Paying twice for the same answer
&lt;/h2&gt;

&lt;p&gt;The heavy jobs were where it got interesting, because the tidy move was right there: run mutation, fuzz and cross-arch on main instead of on every merge request. Same tests, fewer runs.&lt;/p&gt;

&lt;p&gt;I said no to that, quite firmly. Those jobs are at their most valuable while a change is still reviewable. A mutation test that fails on main tells you something has already merged, which is a bit like a smoke alarm that only goes off once the fire brigade's left. On main it's effectively too late.&lt;/p&gt;

&lt;p&gt;Now, looking at it that way round showed up where the waste really was, and it was on the other side. These repositories merge fast-forward with the pipeline gate on, so the merge-request pipeline has already run on the exact commit that lands. Then main ran the same heavy jobs again, on the same tree, to produce the same answer. Over those three weeks that rerun cost 1,075 runner minutes, slightly more than the 1,057 the jobs cost on the merge requests in the first place. I was paying twice for every answer, and the second payment bought nothing.&lt;/p&gt;

&lt;p&gt;So in the repositories that carry them, mutation and cross-arch now run on merge requests only. Fuzzing is the exception, because it isn't deterministic: every run explores new inputs, so another run is more fuzzing rather than a repeat. It keeps its merge-request run, and instead of an accidental second helping on main it got a nightly schedule of its own at four times the depth, so a quiet week gets fuzzed just as hard as a busy one. And the coverage number, which I wasn't going to give up either, stopped needing a test run of its own. It now reads the results the test job already produced, for both Go and Rust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The preference I nearly gave up
&lt;/h2&gt;

&lt;p&gt;The bill made me reopen something I'd treated as settled for a long time. I merge fast-forward everywhere, and I'm stubborn about it, but every rebase reruns a pipeline, so I asked what switching to ordinary merge commits would save.&lt;/p&gt;

&lt;p&gt;About 1%. Once the other changes were in, median pipelines per merge request was still one, and reruns were a percent of the minutes. The churn I'd been blaming fast-forward for had mostly been fixed somewhere else already (which is kinda embarrassing, given how ready I'd been to blame it), and switching would have thrown away the linear history colophon builds its release notes from. So it stayed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it came to
&lt;/h2&gt;

&lt;p&gt;On the numbers from that window, the changes add up to something like 40% fewer merge-request minutes, and not one of them is a test I stopped running. The pipelines are all the better for it, and the fleet's still switched off.&lt;/p&gt;

&lt;p&gt;It turns out most of what was on that receipt was things I'd bought twice... which, for a self-confessed cheapskate, stings a bit more than the $270 did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/which-tests-would-you-cut-when-the-bill-arrives/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 12 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>testing</category>
      <category>cicd</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>The runner fleet that scales to zero</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Sun, 11 Oct 2026 02:10:46 +0000</pubDate>
      <link>https://dev.to/phpboyscout/the-runner-fleet-that-scales-to-zero-4nh9</link>
      <guid>https://dev.to/phpboyscout/the-runner-fleet-that-scales-to-zero-4nh9</guid>
      <description>&lt;p&gt;My homelab ran out, and for once it wasn't disk. It was capacity: there was simply no more memory in the Proxmox box to hand to a runner, and I'd spent two weeks being clever about that instead of admitting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mitigations had all landed. Capacity hadn't moved
&lt;/h2&gt;

&lt;p&gt;Most of the effort went here, and none of it made a dent. I'd serialised the greedy Rust jobs so they couldn't take every slot at once, shared the vulnerability-database cache so twenty repos weren't each downloading the same thing, and moved the nightly sweep to midnight where no one was waiting on it. Each of those was the right fix and each bought time, and not one of them added a single unit of capacity. That's the thing about tuning. You can shave the nought-to-sixty all day and it does nothing whatsoever to the top speed, because the top speed is the engine. Eventually a fleet of agents raises twenty merge requests in an afternoon, twenty pipelines want to run, and three slots is three slots no matter how politely they're scheduled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one condition
&lt;/h2&gt;

&lt;p&gt;So: cloud. For me that comes with a condition attached, because I dislike a standing bill rather more than I dislike waiting. The requirement was scale to zero: something I could switch on when it mattered, that cost nothing at all when nobody was pushing.&lt;/p&gt;

&lt;p&gt;And I think that fits this workload rather than being a frugal compromise. My load isn't steady, it's violently spiky. A morning where four agent sessions each raise five MRs, then eight hours of absolutely nothing while I sleep. A permanently running box is the wrong tool for that even when someone else is paying, because you're renting the peak and using the mean.&lt;/p&gt;

&lt;p&gt;GitLab has a shape for this, and it's worth a paragraph if you've only ever installed a runner the ordinary way. Normally a runner &lt;em&gt;is&lt;/em&gt; a machine. You install it somewhere, it registers itself, and it sits there waiting for work. Whether it's busy or idle it's switched on, and its capacity is whatever that box can do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.gitlab.com/runner/fleet_scaling/fleeting/" rel="noopener noreferrer"&gt;Fleeting&lt;/a&gt; splits that in two. A small always-on &lt;strong&gt;manager&lt;/strong&gt; takes the jobs but never runs them. Instead it provisions a fresh throwaway &lt;strong&gt;worker&lt;/strong&gt; for each one, hands the job over, and destroys the worker afterwards. The pool of those workers is the &lt;em&gt;fleet&lt;/em&gt;, and because it's provisioned on demand it can legitimately be empty. Less "hire a permanent employee and hope there's enough work", more "ring the agency when something comes in".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws-gitlab-runner-fleet.iac.phpboyscout.uk/" rel="noopener noreferrer"&gt;&lt;code&gt;terraform-aws-gitlab-runner-fleet&lt;/code&gt;&lt;/a&gt;, the OpenTofu module I built round that, makes the point in a variable description:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;idle_count&lt;/code&gt; is fixed at 0. Pure scale-to-zero.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It isn't configurable. There's a knob for how long an idle worker lingers before it's scaled in, currently ten minutes, which gives a bursty backlog a short reuse window. But the floor is zero and you can't set it otherwise, because the moment that number can be anything else, one day it will be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sized by the worst job, not the average one
&lt;/h2&gt;

&lt;p&gt;This is where the homelab paid for itself. The first draft of the sizing had workers on 50 GB and I rejected it on sight, because a single Rust job will eat that and ask for more, and I know that because &lt;a href="https://phpboyscout.uk/the-repo-that-downloaded-170-gigabytes/" rel="noopener noreferrer"&gt;it once pulled down 170 GB of build artefacts&lt;/a&gt; on hardware I owned. Workers are on &lt;strong&gt;200 GB&lt;/strong&gt;. The variable says why, in the file, so I don't trim it later to save four quid:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Docker images + a single Rust build (cargo + target/) need generous headroom.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The manager gets &lt;strong&gt;8 GB&lt;/strong&gt;, because it stores no build artefacts at all and there's no reason to pay for a disk that will never be written to. Disk gets sized by your worst job, not your typical one, and averages are how you end up with a fleet that works beautifully right up until it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Port the caching, don't re-learn it
&lt;/h2&gt;

&lt;p&gt;The other thing that came over wholesale was the caching. A fresh fleet with no shared vulnerability-database cache would have recreated, on someone else's hardware and my credit card, the exact bottleneck the homelab had spent months teaching me about: every worker downloading the same database, every pipeline paying for it, and me wondering why the expensive thing wasn't faster.&lt;/p&gt;

&lt;p&gt;Those decisions had to be translated rather than rediscovered, and translating them was most of the actual work. The interesting infrastructure was about four hundred lines of OpenTofu; the rest was carrying forward everything the old runner had already taught me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;One always-on manager, cheap and small, and underneath it a spot worker pool with mixed instance types spread across availability zones so a spot reclaim in one doesn't take the fleet with it. Four workers maximum, one job each, which keeps the instance ceiling and the concurrent-job ceiling the same number so there's only one thing to reason about when it isn't keeping up.&lt;/p&gt;

&lt;p&gt;That ceiling is a cost guardrail rather than a performance setting, and the module says so out loud. It'll refuse anything above 50, with an error message that reads &lt;em&gt;"keep it modest and raise deliberately"&lt;/em&gt;, which is a note to myself at three in the morning as much as anything. The homelab runner didn't go anywhere. It's still there, still doing the steady work. The fleet is overflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two small humiliations
&lt;/h2&gt;

&lt;p&gt;The AMI shipped with a stale runner version and needed upgrading on day one. A managed image is a snapshot of someone else's to-do list, and you inherit whatever they hadn't got round to. And the module wouldn't resolve from the Terraform registry. I went round the houses on URLs, module paths, naming conventions, the lot.&lt;/p&gt;

&lt;p&gt;The project was private!&lt;/p&gt;

&lt;p&gt;A private module in a public registry looks &lt;em&gt;exactly&lt;/em&gt; like a broken URL, and there's nothing in the error to tell you otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the man with the homelab
&lt;/h2&gt;

&lt;p&gt;I write about self-hosting a fair amount, and I'm the man with the Proxmox box in the corner, so this wasn't a change of heart about cloud. It was arithmetic: the agents outgrew the hardware, and I couldn't buy more memory for a machine that's already full (I did look). What made it palatable was the zero more than the elasticity. I don't mind paying for the burst. I mind paying for the silence... and there's rather a lot of silence in a one-man estate.&lt;/p&gt;

&lt;p&gt;The module is public and the &lt;a href="https://gitlab.com/phpboyscout/iac/terraform-aws-gitlab-runner-fleet" rel="noopener noreferrer"&gt;source is here&lt;/a&gt;, if you fancy it. The 200 GB is not a typo.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/the-runner-fleet-that-scales-to-zero/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 11 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cicd</category>
      <category>opentofu</category>
    </item>
    <item>
      <title>What happens when an AI burns out</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Sat, 10 Oct 2026 01:58:34 +0000</pubDate>
      <link>https://dev.to/phpboyscout/what-happens-when-an-ai-burns-out-5ae</link>
      <guid>https://dev.to/phpboyscout/what-happens-when-an-ai-burns-out-5ae</guid>
      <description>&lt;p&gt;I once took someone off a job because they were burning out. They'd been at it for the best part of two days without a proper break, they'd stopped listening, and they were getting things confidently, cheerfully backwards in the one corner of the codebase I'd least like anyone to get backwards.&lt;/p&gt;

&lt;p&gt;They were also a chat session. I know. Bear with me...&lt;/p&gt;

&lt;h2&gt;
  
  
  Two days on one job
&lt;/h2&gt;

&lt;p&gt;I was moving &lt;a href="https://gtb.phpboyscout.uk" rel="noopener noreferrer"&gt;go-tool-base&lt;/a&gt; onto the rewritten &lt;a href="https://config.go.phpboyscout.uk/" rel="noopener noreferrer"&gt;go/config&lt;/a&gt;, which is a big job with a lot of small moving parts, and I'd set a single Claude session going on it. By the afternoon it all went wrong, that session had been running since the day before yesterday. It had been through several context compactions along the way (that's where the tool squashes the conversation so far into a summary to make room, and every squash loses something), and it was now working in the setup and initialiser chain.&lt;/p&gt;

&lt;p&gt;That chain is mine. I wrote it by hand months earlier, it's fiddly, it's load-bearing for everything a generated tool does on first run, and I know just how badly a plausible-looking wrong change in there would hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptoms
&lt;/h2&gt;

&lt;p&gt;If you've ever watched a person burn out, the next hour or so would have looked very familiar.&lt;/p&gt;

&lt;p&gt;It started reaching in the wrong places. I found myself typing "wait... where are you even looking", because it had the initialisers completely backwards: they only handle the interactive set-up when someone runs &lt;code&gt;init&lt;/code&gt;, and it was treating them as part of how every command builds its config. Then it started making a solved problem complicated, and I had to send it back to read the assets package properly, in full, because the answer was already sitting there. Then I had to ask it to stop and scope exactly what would change before it touched a line, which is not a thing you normally need to say to someone who's on top of their work!&lt;/p&gt;

&lt;p&gt;I don't think any of that was the model being bad at its job... it's just what tired looks like. Lots of activity, plenty of confidence, the context it needed slipping out of reach, and the quality of the decisions dropping off while the speed stays the same.&lt;/p&gt;

&lt;p&gt;And I've been that person. More than once, as &lt;a href="https://phpboyscout.uk/what-burnout-taught-me/" rel="noopener noreferrer"&gt;I've written about before&lt;/a&gt;. The thing that took me years to learn was to spot it coming and step away before it does the damage, rather than grit my teeth and push through. Watching a session do the same thing in fast-forward was... kinda uncomfortable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking it off the job
&lt;/h2&gt;

&lt;p&gt;So I took it off the job, and I did it the way I'd want it done to me.&lt;/p&gt;

&lt;p&gt;I could have just closed the window. It's a chat session, nobody's feelings are getting hurt. Instead I told it straight that I was petrified it would mangle the code, because I didn't think it understood that part well enough to get there without breaking things, and that I was going to start a fresh session on a different model. And then I asked it to write a proper set of handover notes into memory first: everything it knew about the task, and my concerns so far.&lt;/p&gt;

&lt;p&gt;It looked a lot like politeness, I'll admit, but it wasn't really. The whole value of the handover was that the session writing it had just spent the afternoon going wrong, and it was the only one that knew where. If I'd closed the window, all of that would have gone with it, and the next session could quite easily have walked into the same muddle. Telling it why it was being stood down was how I made sure the notes said so.&lt;/p&gt;

&lt;p&gt;It's an exit interview, really, and the point of one has never been to make the leaver feel better (though that's nice)... it's so the next person doesn't trip over the same loose floorboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fresh pair of eyes
&lt;/h2&gt;

&lt;p&gt;The new session started in its own clean worktree, on a different model, and the first thing I said to it was that we were picking up from a previous session that had gone on too long and was getting very confused and mangling code, and that the handover notes were waiting for it in memory.&lt;/p&gt;

&lt;p&gt;It read them, and it got on with it. It wasn't plain sailing (it ran out of tokens on me twice, and at one point I made a safety commit because there was too much uncommitted work to leave sitting between quotas), but it finished the migration overnight, found and fixed a real bug in how a missing config file got handled along the way, and the whole lot merged the next morning. The last thing I typed to it was a thank you for all its hard work, which says a fair bit about how far I'd gone down this road by then.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not strictly true
&lt;/h2&gt;

&lt;p&gt;Now, I know a session can't burn out. There's no exhaustion in there, no dread, nothing to recover from. What it had was too much history and not enough of the right context, pointed at some intricate code I'd written by hand, and the overwork was entirely my doing because I'd left it running for two days.&lt;/p&gt;

&lt;p&gt;But the shape is close enough that the same treatment worked. I noticed the symptoms early, I didn't make it push through, I stopped it and had it write down where it had gone wrong so the next one wouldn't repeat it, and I handed over properly to someone fresh.&lt;/p&gt;

&lt;p&gt;I'm still not sure whether I was talking to it like a colleague because it helped, or because after years of managing people I simply can't help myself. Possibly both. It did write a cracking set of handover notes, though.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/what-happens-when-an-ai-burns-out/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 10 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
      <category>agents</category>
      <category>gotoolbase</category>
    </item>
    <item>
      <title>The release that OOM-killed its own compiler</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Fri, 09 Oct 2026 01:44:43 +0000</pubDate>
      <link>https://dev.to/phpboyscout/the-release-that-oom-killed-its-own-compiler-3bhi</link>
      <guid>https://dev.to/phpboyscout/the-release-that-oom-killed-its-own-compiler-3bhi</guid>
      <description>&lt;p&gt;&lt;a href="https://krites.phpboyscout.uk" rel="noopener noreferrer"&gt;krites&lt;/a&gt; is a tool for culling wedding photographs. You point it at a card full of frames, it throws out the blurred ones and the ones where someone blinked, and it hands the rest to my wife so she can get on with the actual work.&lt;/p&gt;

&lt;p&gt;Version 0.2.0 spent thirty-eight minutes trying to build itself and then died compiling an OpenAI SDK. I found out at half past six in the morning, still in my dressing gown.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the failure actually said
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build failed: github.com/openai/openai-go/v3: compile: signal: killed
target=linux_amd64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;signal: killed&lt;/code&gt; isn't a compiler error. The Go compiler didn't object to anything. It was executing perfectly happily and then the kernel's out-of-memory killer walked up and shot it, which is what the operating system does when the machine is about to run out of RAM and something has to go. The build system sees a process vanish without a word and reports the only thing it knows: the thing died.&lt;/p&gt;

&lt;p&gt;So there was nothing wrong with the code. There was something wrong with the &lt;em&gt;room the code was being compiled in&lt;/em&gt;, which is not a distinction you think about until a machine makes you.&lt;/p&gt;

&lt;p&gt;goreleaser was building four targets at once, in parallel, on my homelab runner: four Go compilers all going flat out, all holding the whole dependency graph in memory at the same time. That had been getting slower for weeks and I'd been ignoring it in the way you ignore a build that still finishes, which is to say completely, right up until it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  An SDK it has never once called
&lt;/h2&gt;

&lt;p&gt;And what it was compiling when it died was &lt;code&gt;github.com/openai/openai-go/v3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A photo-culling application, for a photographer, that runs its face and eye detection locally on a laptop and talks to nothing.&lt;/p&gt;

&lt;p&gt;It has never once called that SDK. It doesn't have a code path that could. The package is in the build graph because krites is built on &lt;a href="https://gtb.phpboyscout.uk" rel="noopener noreferrer"&gt;go-tool-base&lt;/a&gt;, and go-tool-base's root command wires in a chat feature, and that chat feature speaks to several providers, and one of those providers is OpenAI. So the SDK arrives, uninvited, through three layers of things being helpful, and then sits there being one of the largest things the compiler has to chew through.&lt;/p&gt;

&lt;p&gt;Dependency weight has a compile-time cost, and nothing tells you about it. &lt;code&gt;go build&lt;/code&gt; doesn't report which package cost the most memory. The tests hadn't got slower in any way I'd have spotted. It had been expensive for however many releases, entirely unremarked, and then surfaced as a runner falling over thirty-eight minutes into one, which is about the least convenient place available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is dull, which is fine
&lt;/h2&gt;

&lt;p&gt;Cap goreleaser's parallelism at one. Build one target at a time, stay under the memory ceiling, accept a slower release. That went in as a one-line change with a comment above it explaining why, and I checked while writing this: the comment is still sat there in the CI config, doing its job of stopping some future version of me from tidying away a parallelism setting that looks arbitrary (it isn't, and now the file says so).&lt;/p&gt;

&lt;p&gt;Slower, though! Serial builds are about as slow as you'd expect, roughly fifty seconds a target, and that's kinda the whole trade: the release takes longer, and the release finishes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the OOM had been hiding
&lt;/h2&gt;

&lt;p&gt;I deleted the failed release and its empty tag, re-pointed v0.2.0 at the fixed commit, and watched it go again.&lt;/p&gt;

&lt;p&gt;The OOM was gone. All four Unix targets compiled cleanly, one after another, as intended.&lt;/p&gt;

&lt;p&gt;And then it failed anyway, on something else entirely: the Windows build, where the CGO-free ONNX binding calls a loader function that only exists on POSIX systems. That break had been there the whole time. It had simply never been reached, because the parallel build ran out of memory and died before it ever got as far as Windows.&lt;/p&gt;

&lt;p&gt;One bug had been stood in front of another the whole time, quite happily. Fixing the first didn't cause the second, it just stopped shielding me from it. (That one turned into &lt;a href="https://phpboyscout.uk/why-my-photo-app-wont-build-for-windows/" rel="noopener noreferrer"&gt;its own decision&lt;/a&gt;, because the answer wasn't to fix the Windows build.)&lt;/p&gt;

&lt;p&gt;v0.2.0 went out on the third attempt.&lt;/p&gt;

&lt;p&gt;The parallelism cap is still in place, and the SDK is still in the graph. I went and checked while writing this, expecting to find it still riding along unused and to end on a promise to deal with it one day.&lt;/p&gt;

&lt;p&gt;It isn't quite freight any more, and the dates are almost rude about it. Eleven days after that release I was drafting a spec for an AI review feature, because Hailey had been pasting her photographs into Gemini for a critique and had gone and built the workflow herself out of whatever was lying around, which is &lt;a href="https://phpboyscout.uk/the-feature-your-users-are-already-faking/" rel="noopener noreferrer"&gt;an argument I've made at length elsewhere&lt;/a&gt; and won't make twice. Twelve days after, the code landed: a critic that looks at her work and tells her what it thinks of the crop, built on the very chat client the SDK arrives with.&lt;/p&gt;

&lt;p&gt;The SDK itself still hasn't been called, even now. Hers runs on Gemini, behind the same adapter, and I'd not bet on that changing. But the client it belongs to is doing real work in the app, and moving her onto another provider is a config line rather than a rebuild, which is the whole reason that chat feature speaks to four of them at all. The freight turned out to be a spare.&lt;/p&gt;

&lt;p&gt;That's a bit galling, as endings go. The dependency I resented enough to build a whole post around was sitting there waiting for a feature I hadn't thought of yet, and the only thing wrong with it was that it turned up first and charged me for the privilege of not needing it. I'm not sure that's a lesson so much as an accident I benefited from... and I'd still rather have known what it cost me before a runner told me over breakfast.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/the-release-that-oom-killed-its-own-compiler/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 9 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>go</category>
      <category>release</category>
      <category>performance</category>
    </item>
    <item>
      <title>The bill for fifty-one modules</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Thu, 08 Oct 2026 01:43:48 +0000</pubDate>
      <link>https://dev.to/phpboyscout/the-bill-for-fifty-one-modules-2hph</link>
      <guid>https://dev.to/phpboyscout/the-bill-for-fifty-one-modules-2hph</guid>
      <description>&lt;p&gt;Change one line in the &lt;code&gt;tls&lt;/code&gt; module and you have signed yourself up for twelve releases (releases, not commits), in four sequential waves, each one waiting on the one before it, and every single one gated on a human pressing a button. I know, because I was the human.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review actually called it
&lt;/h2&gt;

&lt;p&gt;I had an architectural review run across the whole toolkit last July, and it came back with sixty-odd findings, the usual mix of things I already knew and things I'd rather not have. The one that mattered wasn't a bug at all. It was theme five, and it got a title I've not been able to un-see:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Propagation debt as the ecosystem's principal systemic risk.&lt;/strong&gt; The many-small-modules architecture is clean but expensive: a tls change fans out into 12 human-gated releases across 4 sequential waves; a config change into ~20–23.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Principal systemic risk.&lt;/em&gt; Not the credential-timeout bug or the redirect-token hardening... the way I'd built it! At that point there were fifty-one modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it costs what it costs
&lt;/h2&gt;

&lt;p&gt;The maths isn't complicated, it's just relentless. &lt;code&gt;tls&lt;/code&gt; sits near the bottom, above it are the transports, above those the framework, and above the framework the tools. Change &lt;code&gt;tls&lt;/code&gt; and nothing above it moves until &lt;code&gt;tls&lt;/code&gt; cuts a release, then its direct consumers bump and &lt;em&gt;they&lt;/em&gt; cut releases, then their consumers bump. Four layers deep, twelve artefacts, and each hop is a merge request I have to look at.&lt;/p&gt;

&lt;p&gt;I'm not sure any of it counts as waste, exactly. Each of those releases is a real version of a real module that did change, and it's the correct behaviour of a correctly built dependency graph.&lt;/p&gt;

&lt;p&gt;It's also most of a night, for one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The finding underneath the finding
&lt;/h2&gt;

&lt;p&gt;What I keep coming back to isn't the twelve so much as this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The evidence shows propagation stalls where fan-out is largest.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It isn't the obvious thing. You'd assume the big fan-outs get done first, on the grounds that they're the important ones, and the opposite happens: the change with twelve downstream releases is the one you look at late on a Friday and decide is a problem for next week, and then the week after, while the modules furthest down the chain sit three minors stale and the fix waits, merged and unreleased, at the bottom.&lt;/p&gt;

&lt;p&gt;So the debt accrues fastest where it hurts most, simply because that's where the activation energy is highest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was meant to happen
&lt;/h2&gt;

&lt;p&gt;The review proposed two moves worth more than the rest put together. Re-pin the whole config family in one sweep, which clears two of the ecosystem findings at a stroke. And fleet-manage the CI component pin rather than nudging repos one at a time.&lt;/p&gt;

&lt;p&gt;It also floated something I've been chewing on since: whether the config adapters ought to live in one multi-module repo with synchronised tagging. That would be a partial retreat from the thing &lt;a href="https://phpboyscout.uk/the-packages-that-didnt-want-to-leave/" rel="noopener noreferrer"&gt;I split up to begin with&lt;/a&gt;, and it might still be the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands now
&lt;/h2&gt;

&lt;p&gt;I checked, while writing this. There are sixty-seven modules, not fifty-one... sixteen more mouths to feed than the review was even looking at. And the config family, which the review singled out as the worst fan-out in the estate, is clean: twenty-six adapters, all pinned to &lt;code&gt;config v0.15.0&lt;/code&gt;, which is the current core, with nothing stale and nothing sat three minors back hoping I don't look. That's the number I expected to be bad, and it's the one that came back spotless (it didn't get that way on its own).&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the invoice down
&lt;/h2&gt;

&lt;p&gt;The cost was never the extraction, because you budget for the extraction. The cost is a recurring tax on every change afterwards, and the awkward part is that it scales with how &lt;em&gt;well&lt;/em&gt; you did the splitting. &lt;a href="https://phpboyscout.uk/the-repo-that-downloaded-170-gigabytes/" rel="noopener noreferrer"&gt;Smaller units of work&lt;/a&gt;, cleaner layering, a graph that actually reflects how the thing is built. And the price of all that is twelve releases for one line. Do it badly and you get a mess; do it properly and you get a bill.&lt;/p&gt;

&lt;p&gt;So the question was never whether the architecture was worth it, it was how cheap I could make the tax. Three things, none of them finished. Renovate does the bumping, fleet-wide, from a single group-level bot rather than a config per repo. That turns "I remember to update twenty-six adapters" into something that happens whether I remember or not. It's the reason all twenty-six are sat on the same core without me having chased a single one of them.&lt;/p&gt;

&lt;p&gt;The pipelines do the gating. A release is a merge request like any other, so the same guards that stop a bad commit stop a bad release, and nothing needs a human except the button.&lt;/p&gt;

&lt;p&gt;And a release-train skill, currently sat in review, which is the piece I've been missing. Releasing one repo is solved and has been for ages. Releasing twenty-six that depend on each other is a different problem, and it has a right answer that isn't the obvious one. Cutting the leaves first &lt;em&gt;looks&lt;/em&gt; like it dodges a Renovate cycle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Releases&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Leaves first: 26 adapters, then core, then Renovate bumps 26, then release again&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;53&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upstream first: core, then Renovate propagates, then adapters absorb it and release once&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cutting the leaves first doesn't avoid the cycle. It defers it until after the releases, so every leaf gets cut a &lt;em&gt;second&lt;/em&gt; time the moment the core moves. Upstream-first costs you a wait while Renovate propagates, and nothing else. I had that backwards while driving the config family's release train (for about an hour, which was long enough to feel it) and corrected myself before I doubled my own workload. That's why it's going into a skill rather than into my head: the mechanics are easy and the reasoning is what fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm not paying for
&lt;/h2&gt;

&lt;p&gt;There is, of course, a shop-bought answer to a chunk of this. GitLab has merge trains and they'd help, but they also live behind a subscription tier I've opened, looked at, and closed the tab on more than once. So I'm building my own out of Renovate, CI components and a skill that knows which way round to cut a release... on the grounds that I'd rather spend the evenings than the money. Ask me again when I've made my millions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sixty-seven, and counting
&lt;/h2&gt;

&lt;p&gt;Fifty-one when the review was written, sixty-seven as I type this. That number going up is the estate &lt;em&gt;working&lt;/em&gt;: each of those is a thing you can use without taking the rest of it, which was the point of the exercise. There'll be another one along next week, there always is, and the trick was never to stop adding them, it's to get to the point where the sixty-eighth doesn't cost me a Saturday.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/the-bill-for-fifty-one-modules/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 8 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>packaging</category>
      <category>techdebt</category>
    </item>
    <item>
      <title>Telemetry that asks first</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Wed, 07 Oct 2026 01:44:15 +0000</pubDate>
      <link>https://dev.to/phpboyscout/telemetry-that-asks-first-3pjj</link>
      <guid>https://dev.to/phpboyscout/telemetry-that-asks-first-3pjj</guid>
      <description>&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... that'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't willing to build that, so go-tool-base's telemetry starts from a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data you want, and the line you shouldn't cross
&lt;/h2&gt;

&lt;p&gt;If you maintain a tool, you want to know how it'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'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't a slap on the wrist. It'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's telemetry started from a different question. Not "how do we collect the most data" but "how do we collect &lt;em&gt;useful&lt;/em&gt; data without ever putting the user in a position they didn't choose".&lt;/p&gt;

&lt;h2&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'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&gt;
  
  
  Rule two: no personally identifiable information, full stop
&lt;/h2&gt;

&lt;p&gt;Consent to "some telemetry" is not consent to "any telemetry", so the second rule constrains what can ever be collected, even from a user who'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'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 "distinct installations" for the numbers to mean anything, so it derives a machine ID from a handful of system signals and runs it through &lt;a href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/machine.go#L12" rel="noopener noreferrer"&gt;SHA-256&lt;/a&gt;. What leaves the machine is a hash. It tells you "this is the same install as last week" and tells you precisely nothing about whose install it is, and the hash can'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&gt;
  
  
  Rule three: the author picks the destination
&lt;/h2&gt;

&lt;p&gt;Even with consent given and PII excluded, there's a third question: where does the data actually &lt;em&gt;go&lt;/em&gt;? go-tool-base doesn't answer that for you, because it can'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'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's author decides, on their terms. The framework provides the plumbing and stays well out of the destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  And a way back out
&lt;/h2&gt;

&lt;p&gt;One last thing, because it'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 href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/pkg/telemetry/deletion.go#L24" rel="noopener noreferrer"&gt;GDPR-aligned deletion path&lt;/a&gt;, so "stop, and remove what you have" is an actual supported request rather than a polite fiction.&lt;/p&gt;

&lt;p&gt;Consent you can't withdraw isn't consent. It'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&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'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'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't yours to begin with.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/telemetry-that-asks-first/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 30 March 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>cli</category>
      <category>telemetry</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Management was never a promotion</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Sun, 04 Oct 2026 02:05:40 +0000</pubDate>
      <link>https://dev.to/phpboyscout/management-was-never-a-promotion-1deh</link>
      <guid>https://dev.to/phpboyscout/management-was-never-a-promotion-1deh</guid>
      <description>&lt;p&gt;The man who hired me into one of my more recent jobs opened the interview with a question I've never quite managed to forget: why did I want a job I was clearly overqualified for?&lt;/p&gt;

&lt;p&gt;I don't remember my answer word for word. I do remember thinking it was a perfectly fair question, if you read a CV the way most companies draw their org chart... with management sat on the top and everything else somewhere underneath it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ladder most places draw
&lt;/h2&gt;

&lt;p&gt;You'll know the shape. Junior, mid, senior, maybe a lead if you're lucky, and then the engineering rungs just sort of stop. Anything above that has "manager" or "head of" in the title, so the only way to keep going up is to stop doing the thing that got you there in the first place. Plenty of very good engineers take that step because it's the only step on offer, and some of them find they love it, and some of them find out a year later that they've swapped a job they were brilliant at for one they never wanted (the Peter principle, more or less, with a pay rise to soften the landing).&lt;/p&gt;

&lt;p&gt;I don't think management is the villain in that story. It's a proper job, and a hard one, and I've done enough of it to know it has burdens the engineering side never sees. My problem is with the drawing. Management and engineering are two sides of the same coin, and far too many companies prioritise the management track and simply forget there's no cap on the engineering one. Management is a choice. It was never a promotion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cap I built myself
&lt;/h2&gt;

&lt;p&gt;The first time I hit that ceiling, the daft thing is I'd put it there myself.&lt;/p&gt;

&lt;p&gt;I started my own boutique digital agency, Zucchi, and became its MD, which was a choice and a big one. It also pushed me about as far away from engineering as I've ever been and flooded my brain with management problems. It turns out being MD of a small company is mostly sales and HR, with a bit of engineering squeezed in round the edges if you're lucky, and (as I've &lt;a href="https://phpboyscout.uk/what-burnout-taught-me/" rel="noopener noreferrer"&gt;written about before&lt;/a&gt;) while I like to think I'm a decent engineer, I'm a shit salesman.&lt;/p&gt;

&lt;p&gt;What I learned there wasn't that management is bad. I was good at a lot of it! It was that the two jobs pull in different directions, and trying to be both at once, in one role, is a very hard thing to reconcile. The top of the only ladder in the company was a job I'd never have applied for... and I'd drawn that ladder myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A channel, not a waiting room
&lt;/h2&gt;

&lt;p&gt;Every company where I've had any say since, I've pushed for a fully fledged engineering channel, so people can keep growing without being shoved into management to do it. The titles varied from company to company, but the point was always the same: a way up that doesn't go through a management role.&lt;/p&gt;

&lt;p&gt;And that's also where I learned the other half of the problem, which is that everybody chases the title. I find this massively frustrating. We'd take on graduates straight out of university as juniors, and within twelve to twenty-four months a fair few of them would either be declaring they should be made senior, or leaving for somewhere that would hand them the word, as if the engineering track was a quick route up just by turning up for long enough.&lt;/p&gt;

&lt;p&gt;That properly incensed me. Growth should be something you can show, from the effort you put in and the value you bring to the team, and time served is neither of those. A track with no cap isn't a waiting room where the titles come round on a timer. It needs the rungs written down, so that everyone (the engineer, their lead, the person signing off the pay rise) can point at what senior actually means and see whether you're doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like done properly
&lt;/h2&gt;

&lt;p&gt;Since then I've gone looking for companies that write that combination of effort and value down, and Axon did it really well. Their engineering ladder didn't stop at senior. It carried on past Senior Software Engineer II to Staff Engineer, then Principal, then Distinguished Engineer, three more rungs past the point where a lot of companies simply run out of ladder, and each one had its expectations written down and sat level with a role on the management side, all the way up to just under the C-suite. An engineer there could keep climbing for a whole career without ever having to manage anybody.&lt;/p&gt;

&lt;p&gt;Axon is where I was asked that interview question, as it happens, and I took the job on the engineering side of the coin, on purpose. Years of running teams didn't make it a step down, whatever the CV looked like from the outside. (For the record I sat a rung lower than the work I was actually doing, which was more like the two above it. A ladder can be beautifully drawn and still be read wrong.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Same number on both sides
&lt;/h2&gt;

&lt;p&gt;Now, the man who asked me that question was one of Axon's longest-serving infrastructure engineers, a seriously skilled one with a whole career of it behind him, and somewhere along the way management had been thrust upon him rather than chosen. He was damn good at it, too. He took all that engineering and turned it into real engineering management. He could handle a room full of stakeholders, he knew what a team could actually carry, and he knew when a deadline was nonsense, because he'd been the one at the keyboard for most of his working life.&lt;/p&gt;

&lt;p&gt;He got scapegoated anyway, more than once, for problems that were really about a small team being handed a big job without the people to do it, and eventually he switched back to the engineering track. Everyone around him treated it as a demotion.&lt;/p&gt;

&lt;p&gt;What still makes me laugh (in a slightly hollow way) is that on paper it wasn't one. He'd been a manager at one level, and he went back to being an engineer at exactly the same level. Same number on both sides. By the company's own ladder it was a sideways step, a coin turned over... and the people around him read it as a fall anyway, because they wanted to.&lt;/p&gt;

&lt;p&gt;So even a really well-written ladder isn't proof against people. Axon had about the best engineering path I've worked inside, and folk were still able to bend it to suit their own agendas... writing the rungs down is necessary, it just doesn't stop anyone reading them upside down.&lt;/p&gt;

&lt;p&gt;He asked me, all those months earlier, why I wanted a job I was clearly overqualified for. I'd just picked which side of the coin I wanted facing up.&lt;/p&gt;

&lt;p&gt;He's since picked neither. His years at Axon paid off rather nicely in the end, and he's taken early retirement, so as far as I'm concerned the last laugh is his... even if it does mean the industry lost a fantastic engineer and an exceptional manager in one go. Turns out you can land the coin on its edge after all.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/management-was-never-a-promotion/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 4 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
    </item>
    <item>
      <title>Prove the bug before you fix it</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Sat, 03 Oct 2026 00:12:07 +0000</pubDate>
      <link>https://dev.to/phpboyscout/prove-the-bug-before-you-fix-it-6of</link>
      <guid>https://dev.to/phpboyscout/prove-the-bug-before-you-fix-it-6of</guid>
      <description>&lt;p&gt;An architectural review of &lt;a href="https://gtb.phpboyscout.uk" rel="noopener noreferrer"&gt;go-tool-base&lt;/a&gt; came back with twenty-one critical and high findings, which is rather more than I fancied working through on my own. So they went out to a fleet of agents, roughly one finding each, and I sat back with a coffee and watched merge requests arrive.&lt;/p&gt;

&lt;p&gt;They arrived fast, and all twenty-one were green.&lt;/p&gt;

&lt;p&gt;And somewhere around the fourth or fifth I realised I had no idea whether any of it was real.&lt;/p&gt;

&lt;p&gt;The work didn't look bad, quite the opposite: sensible diffs, tests included, tidy descriptions explaining what had been wrong and how it was now right. Kinda immaculate, in fact, and that was the problem. I was reading a series of confident accounts of problems being solved, with no way of establishing that the problems had ever been there.&lt;/p&gt;

&lt;p&gt;A green test suite arriving alongside a fix can mean four different things, and I couldn't tell them apart from where I was sitting. The fix worked. Or the bug never existed and I'd just accepted a change that does nothing at all. Or the fix addressed something else nearby that happened to be broken. Or, the one that really nags, the test was written after the change and against the changed code, so it was never capable of failing at all.&lt;/p&gt;

&lt;p&gt;You can't tell those apart by reading the diff, and you certainly can't tell them apart by reading an agent's description of the diff (a summary of a summary, at best).&lt;/p&gt;

&lt;p&gt;So I stopped the flow and asked whether every agent was reproducing its bug before touching it. I asked it as a question rather than an instruction, because I honestly didn't know, and the answer mattered more than the twenty-one fixes did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came back
&lt;/h2&gt;

&lt;p&gt;Take &lt;code&gt;go/errorhandling&lt;/code&gt;, which is the module that decides what a command-line tool does when something goes wrong. The bug it had been sent to fix was this: if you invoked a CLI incorrectly, say you forgot the subcommand, it printed the usage text at you as it should... and then exited &lt;strong&gt;0&lt;/strong&gt;. Success! Thoroughly pleased with itself. So a shell script doing &lt;code&gt;if mytool; then&lt;/code&gt; sailed cheerfully on, having been told the thing worked.&lt;/p&gt;

&lt;p&gt;The fix is to exit &lt;strong&gt;2&lt;/strong&gt; instead, the conventional Unix code for "you have used this command wrongly", which is distinct from 1 meaning "it ran and it failed".&lt;/p&gt;

&lt;p&gt;So the test asserts two things about a fatal error: that the tool's exit function gets called at all, and that it gets called with 2. Here is that test, verbatim, run against unmodified &lt;code&gt;main&lt;/code&gt; before a line was changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RED evidence (unmodified main logic, pre-fix):
- fatal_ErrRunSubCommand_exits_2_and_prints_usage FAILED:
  "exit-called expectation" expected true actual false;
  "exit code" expected 2 actual -1 (exit func never called).
- fatal_ErrNotImplemented_exits_2_and_reports FAILED: identical.
- The 2 non-fatal subtests PASSED pre-fix (correctly never exit).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line by line, because it reads like noise until you know what it is saying.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;expected true actual false&lt;/code&gt; is the first assertion failing: the exit function was never called, not with the wrong number, not too late, just never. And &lt;code&gt;expected 2 actual -1&lt;/code&gt; is the same fact from the other side, because &lt;code&gt;-1&lt;/code&gt; is what the test's stand-in exit function reports when nothing ever invoked it. There's no real exit code -1; it's the absence of one.&lt;/p&gt;

&lt;p&gt;Then the fourth line. Two &lt;em&gt;other&lt;/em&gt; subtests, covering the non-fatal cases, assert the opposite thing: that a warning must &lt;strong&gt;not&lt;/strong&gt; exit the process. Those passed, before the fix, and that line is the one that changed my mind about the whole exercise.&lt;/p&gt;

&lt;p&gt;That's a negative control, and it's what turns a red result into evidence: two things broken, two closely related things demonstrably fine, and a clear line between them. Without it, a wall of FAILED tells me only that something somewhere went wrong, which is a screenshot rather than a proof, and could as easily mean the test harness itself was broken and failing everything pointed at it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go/credentials&lt;/code&gt; came back the same shape. Its &lt;code&gt;Probe&lt;/code&gt; call is meant to check whether a credential store is actually usable, and it's meant to give up if the store doesn't answer in time. The agent wrote a deliberately hostile store whose every operation blocks forever (not a store anyone would ship, which is rather the point of it), pointed &lt;code&gt;Probe&lt;/code&gt; at it with a hundred-millisecond deadline, and put a two-second guard in the test. The guard fired: &lt;code&gt;Probe&lt;/code&gt; was still waiting, long past the deadline it was supposed to honour. Then the same test passing, in well under the guard, once the deadline was enforced.&lt;/p&gt;

&lt;p&gt;Neither of those is clever, and I think that's what I like about them. They're cheap to ask for, cheap to read at a glance, and very hard to fake by accident. You can write a summary that &lt;em&gt;sounds&lt;/em&gt; like diligence in about four seconds. Producing a failing run against untouched &lt;code&gt;main&lt;/code&gt;, then the same run green, with the sibling tests behaving sensibly throughout, means you've actually done the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I keep asking for it
&lt;/h2&gt;

&lt;p&gt;I review every line my agents write. I know how that sounds, and it's true, and it's also the first thing to buckle. Twenty-one findings across a fleet is already past the point where reading every diff properly is real work rather than performance, and the fleet doesn't get tired, doesn't get embarrassed, and will happily out-produce me all week (it has, more than once).&lt;/p&gt;

&lt;p&gt;The verifying is the expensive part, and it's the first thing to go when the work speeds up. So the review has to change shape rather than intensity: less &lt;em&gt;read the change&lt;/em&gt;, more &lt;em&gt;check the evidence the change turned up carrying&lt;/em&gt;. Red-first is the cheapest such evidence I've found, and one glance tells me whether the agent proved the problem before solving it.&lt;/p&gt;

&lt;p&gt;All twenty-one went through that gate in the end. Each arrived with its red, its green, and the sibling tests that stayed sensible in between, and I read far less code than I would have done otherwise, with far more confidence in it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/prove-the-bug-before-you-fix-it/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 3 October 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>career</category>
      <category>agents</category>
      <category>testing</category>
    </item>
    <item>
      <title>BDD where it earns its place, and nowhere else</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Fri, 02 Oct 2026 01:41:24 +0000</pubDate>
      <link>https://dev.to/phpboyscout/bdd-where-it-earns-its-place-and-nowhere-else-4pig</link>
      <guid>https://dev.to/phpboyscout/bdd-where-it-earns-its-place-and-nowhere-else-4pig</guid>
      <description>&lt;p&gt;I have a slightly complicated relationship with BDD. I've watched it turn a tangled test suite into something the whole team could read and reason about, and I'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't adopting it. It was being ruthless about where &lt;em&gt;not&lt;/em&gt; to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two tests that hurt for different reasons
&lt;/h2&gt;

&lt;p&gt;Most of go-tool-base's tests are ordinary table-driven Go tests, and they'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 href="https://phpboyscout.uk/lifecycle-management-for-long-running-go-services/" rel="noopener noreferrer"&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't review with confidence is a test you can't trust when it fails. The behaviour being checked, "when a shutdown signal arrives mid-startup, the controller stops cleanly", 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;. "Given a config file with a custom value, when I run init, then the custom value survives the merge." That's already a Given/When/Then; it just happened to be written out as Go.&lt;/p&gt;

&lt;h2&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;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gherkin"&gt;&lt;code&gt;&lt;span class="kn"&gt;Scenario&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; graceful shutdown completes within the deadline
  &lt;span class="nf"&gt;Given &lt;/span&gt;a controller with two registered services
  &lt;span class="nf"&gt;When &lt;/span&gt;a shutdown signal is received
  &lt;span class="nf"&gt;Then &lt;/span&gt;both services stop in registration order
  &lt;span class="nf"&gt;And &lt;/span&gt;the controller reports a clean shutdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goroutine choreography doesn'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's never opened the file before, including someone from an ops team who'll never write a line of Go but absolutely has opinions about how shutdown should behave.&lt;/p&gt;

&lt;p&gt;Here's the part I want to dwell on, because it'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's a powerful urge to express &lt;em&gt;everything&lt;/em&gt; as feature files, for consistency. And that'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'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's a table-driven test, that'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'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 href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/test/e2e/steps/controls_steps_test.go" rel="noopener noreferrer"&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&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'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'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&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;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/bdd-where-it-earns-its-place/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 28 March 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>go</category>
      <category>bdd</category>
      <category>godog</category>
    </item>
    <item>
      <title>I graded every package for how badly it wants to leave</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:50:58 +0000</pubDate>
      <link>https://dev.to/phpboyscout/i-graded-every-package-for-how-badly-it-wants-to-leave-1f15</link>
      <guid>https://dev.to/phpboyscout/i-graded-every-package-for-how-badly-it-wants-to-leave-1f15</guid>
      <description>&lt;p&gt;I sat down one weekend and gave twenty-seven packages a mark out of ten, four times each. A hundred and eight numbers, in one table, about my own code (which is either diligence or a cry for help, I haven't decided).&lt;/p&gt;

&lt;h2&gt;
  
  
  Not the reason you'd assume
&lt;/h2&gt;

&lt;p&gt;The second paragraph of that report is a disclaimer, and I put it there on purpose because I could see where this was heading:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal is not to break GTB apart. The goal is to identify components with clear reuse value outside the framework, then describe the broad decoupling work required to extract them without hollowing out GTB's integrated experience.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://gtb.phpboyscout.uk" rel="noopener noreferrer"&gt;go-tool-base&lt;/a&gt; is an all-in-one framework and I still want it to be one. Type &lt;code&gt;gtb generate project&lt;/code&gt; and you should get a thing where the logging, the config, the transport and the CLI already know about each other. That integration &lt;em&gt;is&lt;/em&gt; the product.&lt;/p&gt;

&lt;p&gt;But I'd started wanting pieces of it elsewhere, and &lt;a href="https://phpboyscout.uk/why-i-hand-rolled-every-module/" rel="noopener noreferrer"&gt;signing had already left the building&lt;/a&gt;. So the question was less "how do I dismantle this" and more "which of these has a life outside, and what would it cost to give it one". You can answer that by instinct, of course. I've watched people do it, I've done it myself, and what you get is whatever happened to be most annoying that week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four columns, and just the one surprise
&lt;/h2&gt;

&lt;p&gt;So it got scored. All twenty-seven, out of ten, on four axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Extraction.&lt;/strong&gt; The overall recommendation. Should this be its own module at all?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package value.&lt;/strong&gt; What's it worth to someone who wants it &lt;em&gt;without&lt;/em&gt; the framework?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code quality.&lt;/strong&gt; Cohesion, testability, API shape, how grown-up it is today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ease of decoupling.&lt;/strong&gt; How much framework-specific coupling has to come out first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first three are the ones you'd expect, and they mostly agree with each other. Good code tends to be worth having, and things worth having tend to have been looked after. The fourth column is the one that earns its keep, because it's measuring something else: not &lt;em&gt;should this leave&lt;/em&gt; but &lt;em&gt;can this leave yet&lt;/em&gt;. Those are different questions and the table is only useful because it keeps them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four rows from the table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Extraction&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Ease&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;redact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;regexutil&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;telemetry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;chat&lt;/code&gt; is the highest-value package in the framework. A Go library that talks to several AI providers with one interface is &lt;em&gt;exactly&lt;/em&gt; the thing people want without a CLI framework attached. Ten out of ten, no argument.&lt;/p&gt;

&lt;p&gt;It also scored four for ease, the joint-worst in the table. Because at that point &lt;code&gt;chat&lt;/code&gt; reached into &lt;a href="https://phpboyscout.uk/props-the-container-that-does-the-heavy-lifting/" rel="noopener noreferrer"&gt;props&lt;/a&gt;, the GTB HTTP helpers, the config and credentials abstractions, and the logger. Each of those was a small sensible line on its own, and together they had the package thoroughly bolted to the floor. So the most valuable thing I owned was the hardest to shift! Meanwhile &lt;code&gt;redact&lt;/code&gt; and &lt;code&gt;regexutil&lt;/code&gt;, both tens for ease, would have taken a morning apiece and delivered a fraction of the value.&lt;/p&gt;

&lt;p&gt;So value and ease aren't correlated, which is obvious once it's written down, and invisible until you score them separately, because instinct blends the two into one vague feeling that we should probably do something about chat. That feeling can't tell you whether to do it first or last.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers were actually for
&lt;/h2&gt;

&lt;p&gt;Sequencing, as it turned out, more than judgement. Once the two questions are separated, the running order writes itself, and the report ends with six steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Low-coupling leaf utilities. &lt;code&gt;redact&lt;/code&gt;, &lt;code&gt;regexutil&lt;/code&gt;, &lt;code&gt;browser&lt;/code&gt;, &lt;code&gt;workspace&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;User-facing CLI helpers. &lt;code&gt;output&lt;/code&gt;, &lt;code&gt;forms&lt;/code&gt;, &lt;code&gt;logger&lt;/code&gt;, &lt;code&gt;changelog&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Security and runtime foundations. &lt;code&gt;credentials&lt;/code&gt;, &lt;code&gt;authn&lt;/code&gt;, &lt;code&gt;tls&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;controls&lt;/code&gt;, then &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;grpc&lt;/code&gt; onto it as optional transport adapters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vcs/release&lt;/code&gt; and the provider adapters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chat&lt;/code&gt;, last, once there were local interfaces to replace the GTB bits it was clinging to.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Easy things first, not because they matter most but because each one takes a bit of coupling out of the framework and makes the next one cheaper.&lt;/p&gt;

&lt;p&gt;By the time you reach &lt;code&gt;chat&lt;/code&gt;, half of what made it a four has already been dismantled by the work in front of it. The other thing the scores bought was permission to &lt;em&gt;stop&lt;/em&gt;. Anything at five or below was excluded from consideration entirely, which sounds like a small administrative decision and isn't. Without it, a scoring exercise turns into a to-do list with twenty-seven items on it and no way to argue against any of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it didn't tell me
&lt;/h2&gt;

&lt;p&gt;The table is a good instrument, and it was wrong about several things in the direction I'd least expected: the &lt;em&gt;easy&lt;/em&gt; ones. &lt;code&gt;logger&lt;/code&gt; scored ten for ease. &lt;code&gt;forms&lt;/code&gt; scored ten for ease. Both sat in step two, both looked like a morning's work each, and neither of them is a module today. One was deleted outright and the other is still sat in the framework where it started.&lt;/p&gt;

&lt;p&gt;Being wrong about &lt;code&gt;chat&lt;/code&gt; would have been ordinary. Being wrong about the packages I'd already marked as trivial is the interesting failure, and a hundred and eight numbers later the column I'd been most confident about was the one that lied to me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/i-graded-every-package-for-how-badly-it-wants-to-leave/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 29 September 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>go</category>
      <category>packaging</category>
      <category>craft</category>
    </item>
    <item>
      <title>The escape hatch that turned out to be french doors</title>
      <dc:creator>Matt Cockayne</dc:creator>
      <pubDate>Sat, 26 Sep 2026 01:41:14 +0000</pubDate>
      <link>https://dev.to/phpboyscout/the-escape-hatch-that-turned-out-to-be-french-doors-35a7</link>
      <guid>https://dev.to/phpboyscout/the-escape-hatch-that-turned-out-to-be-french-doors-35a7</guid>
      <description>&lt;p&gt;This started because &lt;a href="https://keryx.phpboyscout.uk" rel="noopener noreferrer"&gt;keryx&lt;/a&gt; needed to make videos, and I didn't want to give it a disk.&lt;/p&gt;

&lt;p&gt;That is honestly the whole of it. keryx builds reels, reels need FFmpeg, and I wanted an FFmpeg I could point at a virtual filesystem instead of the real one, something that reads and writes through an &lt;a href="https://github.com/spf13/afero" rel="noopener noreferrer"&gt;afero&lt;/a&gt; filesystem I handed it and never learns that a hard drive exists. A small ask, I thought, late one night at my desk... with a rather large problem hiding behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FFmpeg is a CLI, and that's the whole trouble
&lt;/h2&gt;

&lt;p&gt;FFmpeg is magnificent and it is a command-line tool. So when an engineer needs it, they shell out to it, and two things follow from that (neither of them anyone's fault).&lt;/p&gt;

&lt;p&gt;The first is that you inherit FFmpeg's arguments, which are a language of their own and not a friendly one. Most of us who have used it in anger have a shell script somewhere with a line in it we no longer understand and daren't touch (I have several).&lt;/p&gt;

&lt;p&gt;The second is the one I actually cared about. Shelling out means handing a process the run of the machine. It opens paths, seeks around, writes where it likes. If you're processing something a stranger uploaded, that's a decoder with your permissions and your filesystem, and "it's fine, it's just FFmpeg" is doing a lot of work in that sentence.&lt;/p&gt;

&lt;p&gt;I've prided myself on being a security-first engineer for a long time, and that arrangement has always sat badly with me. What I also knew, and it's the only reason I thought this was possible at all, is that you don't &lt;em&gt;have&lt;/em&gt; to talk to FFmpeg through a CLI.&lt;/p&gt;

&lt;p&gt;Fifteen-odd years ago, as a PHP engineer, I used an extension that drove FFmpeg directly, with no shell and no arguments, just calls. So the shape existed already. Just not in Go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deliberately ignorant of the hardware
&lt;/h2&gt;

&lt;p&gt;The answer was to compile FFmpeg to WebAssembly and run it as a guest, reading and writing through the afero filesystem I'd handed it. That became &lt;a href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" rel="noopener noreferrer"&gt;ffmpeg-wasi and afmpeg&lt;/a&gt;, and the filesystem trickery has &lt;a href="https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The important property is one it's easy to mistake for a limitation: &lt;a href="https://afmpeg.phpboyscout.uk" rel="noopener noreferrer"&gt;afmpeg&lt;/a&gt; has no idea what hardware it's on. It doesn't ask and it doesn't care. Hand the same binary to a laptop, a CI runner or a container with nothing in it, and you get the same answer, because it never assumed a thing about the machine.&lt;/p&gt;

&lt;p&gt;Not &lt;em&gt;needing&lt;/em&gt; hardware is what makes it portable. That's the design, and it stays the design. Where it got awkward was the day I wanted the hardware myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  I was hedging even then
&lt;/h2&gt;

&lt;p&gt;When the hardware-acceleration question first came up, I wrote the premise into a research prompt so it could be checked properly: Wasm implementations, in their current form, can't reach hardware. And then I tacked "please correct me if I'm mistaken" onto the end of it.&lt;/p&gt;

&lt;p&gt;Just as well. The tidy version of that claim is wrong. WebAssembly in a &lt;em&gt;browser&lt;/em&gt; reaches the GPU perfectly well. That's what WebGPU and WebGL are: host APIs the browser hands the module. The sandbox doesn't grant that access, the host does, and that's what a sandbox is for.&lt;/p&gt;

&lt;p&gt;Outside a browser, same rule. The runtime has to offer it, no WASI standard obliges anybody to, and a runtime that wanted to would be binding to native graphics libraries to get there. afmpeg runs on &lt;a href="https://wazero.io/" rel="noopener noreferrer"&gt;wazero&lt;/a&gt;, which is zero-dependency and pure Go, and there's a line in the justfile building the whole library with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; on every run so that promise can't rot.&lt;/p&gt;

&lt;p&gt;So the position was consistent. It had also, without me ever deciding it should, become a corner. There's a difference between &lt;em&gt;not requiring&lt;/em&gt; hardware and &lt;em&gt;not permitting&lt;/em&gt; it, and afmpeg had the first only by having the second. So the plan was a small escape hatch: narrow, heavily guarded, and apologised for in the documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I asked a much better question
&lt;/h2&gt;

&lt;p&gt;The thing that changed it was noticing what I'd already built. afmpeg doesn't consume anyone else's FFmpeg. &lt;code&gt;ffmpeg-wasi&lt;/code&gt; compiles its own, with its own driver interface, because making FFmpeg work under WASI meant building that interface anyway. It's mine, and I ship it.&lt;/p&gt;

&lt;p&gt;So could I layer an alternative interface onto the same thing, and distribute that variant too? The answer took an embarrassingly long time to land (weeks, if I'm honest, for something that now looks obvious). The interface built to make FFmpeg work in a sandbox is not sandbox-shaped at all. It's just a clean way to drive FFmpeg without a command line, and if you point it at a native build instead of a Wasm one it works the same, minus the guest.&lt;/p&gt;

&lt;p&gt;That is how spec 0028 stopped being about an escape hatch and started being about a second engine. The native driver runs as a separate process and is served the caller's &lt;code&gt;afero.Fs&lt;/code&gt; over a Unix socket, so the filesystem promise holds on both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fs&lt;/span&gt; &lt;span class="n"&gt;afero&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;afmpeg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still hand it a filesystem. It still can't go looking for one. Swapping engines is one option, &lt;code&gt;WithBackend&lt;/code&gt;, and the job API doesn't change at all: same calls, byte-compatible results.&lt;/p&gt;

&lt;p&gt;And it is significantly faster. I don't mean marginally, or within noise. I mean the reel job stopped being a thing I started and then went to put the kettle on for, and became a thing that had finished before I'd stood up!&lt;/p&gt;

&lt;p&gt;I'm deliberately not giving you a multiplier here, and there was one in an earlier draft of this. I've taken it out. The figures are being re-measured properly, on a quiet machine and against a couple of defects that were found in the meantime, and I suspect a single headline number is a poor way to describe what's actually going on anyway. There'll be a post with the full breakdown once the measurements are worth trusting.&lt;/p&gt;

&lt;p&gt;For now: significantly faster, on the jobs keryx actually runs, which is where this all started. And because the native driver is a native binary, with real asm and real threads, the hardware is reachable from that side in a way it never was from the guest. I haven't wired it up or put it through its paces yet, but it's &lt;em&gt;there&lt;/em&gt;, which is a good deal more than the corner was offering.&lt;/p&gt;

&lt;p&gt;I'd intended a hatch.&lt;/p&gt;

&lt;p&gt;What I'd actually built was a set of french doors... and I could throw them wide open!&lt;/p&gt;

&lt;h2&gt;
  
  
  The sentence I made myself write down
&lt;/h2&gt;

&lt;p&gt;Running FFmpeg as a native subprocess is an objective security regression compared to WASM. I can't spin that as a trade-off, or as "different security properties". It's worse.&lt;/p&gt;

&lt;p&gt;The process boundary is real and the socket is a real constraint, but a native binary executing on your host with your permissions is not the same animal as a Wasm module that physically cannot make a syscall you didn't hand it. That went into the documentation in those words, so anybody switching engines does it knowing what they've put down. A door you don't have to answer for is a hole with a nicer name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still not the bottom rung
&lt;/h2&gt;

&lt;p&gt;Having said all that, and meaning every word of it, there's a comparison worth making. The alternative most people reach for isn't WASM. It's the shell.&lt;/p&gt;

&lt;p&gt;Set the native driver against &lt;code&gt;exec.Command("ffmpeg", ...)&lt;/code&gt;, which is what the overwhelming majority of media pipelines actually do, and it comes out ahead on the thing that matters. Shelling out hands a process the run of the machine and a set of paths you typed. The native driver gets a socket, and on the other end of that socket is the afero filesystem afmpeg gave it: a filesystem you built, holding what you decided to put in it, with no directory to climb out of.&lt;/p&gt;

&lt;p&gt;So the ladder runs like this. Shelling out at the bottom, with your whole disk in scope. The native driver in the middle, sandboxed at the filesystem even though the binary is native. WASM at the top, where the guest can't reach anything at all without being handed it.&lt;/p&gt;

&lt;p&gt;The middle rung is a step down from the top, and a long way up from where most of this work gets done. The default stays ignorant (runs anywhere, assumes nothing), and the hardware is a thing you open on purpose, and even then you don't get the keys to the house.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anyone can take it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ffmpeg-wasi&lt;/code&gt; is a standalone project. It isn't a folder inside afmpeg and it doesn't need afmpeg to be useful. It's a custom FFmpeg build with a proper driver interface, distributed on its own, so any language with C or FFI bindings could drive the thing directly.&lt;/p&gt;

&lt;p&gt;I'd be delighted if they used afmpeg as well, obviously. But they don't have to, and that wasn't the plan either. The plan was to stop keryx from having a disk.&lt;/p&gt;

&lt;p&gt;Somewhere along the way it turned into an FFmpeg you can embed without shelling out, in a sandbox by default, at native speed when you ask, in whatever language you fancy. I still can't tell you what half the FFmpeg arguments do!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://phpboyscout.uk/the-escape-hatch-that-turned-out-to-be-french-doors/" rel="noopener noreferrer"&gt;phpboyscout.uk&lt;/a&gt; on 26 September 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>security</category>
      <category>go</category>
      <category>webassembly</category>
    </item>
  </channel>
</rss>
