<?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: Allen Brooks</title>
    <description>The latest articles on DEV Community by Allen Brooks (@earnestamateur).</description>
    <link>https://dev.to/earnestamateur</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%2F4054039%2Fbd5bffe9-6dfe-4511-90d5-eda197a92971.png</url>
      <title>DEV Community: Allen Brooks</title>
      <link>https://dev.to/earnestamateur</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/earnestamateur"/>
    <language>en</language>
    <item>
      <title>Invariant checks: the comment is the check you never wrote.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:30:43 +0000</pubDate>
      <link>https://dev.to/earnestamateur/invariant-checks-the-comment-is-the-check-you-never-wrote-3dc</link>
      <guid>https://dev.to/earnestamateur/invariant-checks-the-comment-is-the-check-you-never-wrote-3dc</guid>
      <description>&lt;p&gt;Twelve days ago I built a check that reads my company standards and asserts that every enforcement mechanism they name actually exists as code. Pointed at the repository where those standards are written, it found three rules describing gates nobody had built. I repaired all three, wired the check into that repository's pipeline, and watched it go green.&lt;/p&gt;

&lt;p&gt;This afternoon, while drafting this post, I pointed the same check at a different repository: one that consumes those standards, and where it had never run once.&lt;/p&gt;

&lt;p&gt;A hundred and forty-one claims across fifty files. Three of them unbacked, and two were the same fabricated gates I'd repaired twelve days earlier, sitting untouched in a tree I ship to.&lt;/p&gt;

&lt;p&gt;The check was fine. It was correct on the first day and it's correct now. What I want to write about is the class of assertion it belongs to, why I'd been writing that class without a name for it, and the specific mechanical reason a repair I genuinely made was still not true in the place it needed to be true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comment
&lt;/h2&gt;

&lt;p&gt;Somewhere in your codebase there's probably a comment like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you add a column here, remember to add it to the SET map below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to be precise about what that comment is. It isn't documentation. It's a person who, at the moment of writing it, held in their head the complete specification of a check: the two lists, the direction of the relationship, and the failure. They had everything they needed. They wrote a sentence to a stranger instead, and the stranger is in a hurry.&lt;/p&gt;

&lt;p&gt;That comment is the check you never wrote. It marks the exact coordinate of a hazard and does nothing about it, and the reason it feels responsible is that it genuinely is more responsible than saying nothing. It's a local maximum. You can sit in one for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three properties, all required
&lt;/h2&gt;

&lt;p&gt;I call the thing that comment should have been an invariant check. Three properties have to hold in the check at once, and missing any one of them means you've built something else.&lt;/p&gt;

&lt;p&gt;First, though, the word "pairing," because the whole class turns on it. A pairing is two things that have to be added together and routinely aren't. In the comment above it's the column and its entry in the SET map. Add one without the other and nothing complains, because nothing is watching the relationship between them. That relationship is the pair. A behavior test asks whether one thing does what it should; an invariant check asks whether both halves are still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check is mechanically derived.&lt;/strong&gt; It computes both sides rather than restating one. A new column, enum member, or route joins its scope without anyone editing the check. The moment you hand-enumerate one of the two lists you've created a second artifact that can drift from the first, and now you've got two problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check asserts a pairing.&lt;/strong&gt; When it fails, it names the missing half. Not "this value is wrong" but "you added this and not that." The failure message is a coordinate, not a judgment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check belongs to no single feature.&lt;/strong&gt; One piece of work creates the seam and a different piece of work, months later, violates it. That's why no feature's own tests cover it: at the moment the pairing is created there's nothing to catch, and at the moment it breaks, nobody's looking at the place it was created.&lt;/p&gt;

&lt;p&gt;That third property is what makes these invisible. A unit test belongs to a feature. An invariant check belongs to the space between two features, which belongs to nobody, which is why it doesn't get written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four of them already existed
&lt;/h2&gt;

&lt;p&gt;When I went looking, I found I'd written four of these already. None of them were called anything.&lt;/p&gt;

&lt;p&gt;One asserts that every database table exported by the schema has at least one reader outside the test suite, which catches a table you built, wired into tests, and never actually used. One is a runtime throw at construction time: registering a channel without a client fails immediately and loudly, rather than at three in the morning on the first real message. One asserts that every rule file over a size threshold has a sibling document holding its detail. One asserts that every deprecation tagged with a retirement issue is owned by an issue that's still open.&lt;/p&gt;

&lt;p&gt;Four instances of one idea, in two repositories, in three completely different mechanisms, every one of them written reactively after the corresponding gap had already bitten me.&lt;/p&gt;

&lt;p&gt;The mechanism is not the definition, which took me a while to see. A construction-time throw is as much an invariant check as a scheduled scanner. Choosing between them is a question of where the pairing can be observed most cheaply, not a question of what kind of artifact you're building.&lt;/p&gt;

&lt;p&gt;And the reason to name the class isn't tidiness. An unnamed pattern can't be asked for at planning time, can't be audited for at the level of a whole board, and can't be hunted for by anyone who has no word for it. Naming it is what turns four accidents into something a gate can require. So now the question gets asked when work is planned: does this create a pairing that nothing asserts? "Nothing pairs here" is a fine answer. Nobody asking is the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fifth one, written on purpose
&lt;/h2&gt;

&lt;p&gt;Then I built one deliberately and pointed it at my own standards.&lt;/p&gt;

&lt;p&gt;Those standards are rule files plus longer sibling documents holding the detail, and they're the first thing my agents read before doing anything. The invariant is that every enforcement mechanism a rule &lt;em&gt;names&lt;/em&gt; resolves to code that exists. The pairing is a claim in the corpus and an implementation in the repository.&lt;/p&gt;

&lt;p&gt;The first sweep found three rules that specified enforcement in implementation-level detail and had shipped none of it.&lt;/p&gt;

&lt;p&gt;The first of the three described a gate that would refuse to promote work to ready-for-dev without a test design, complete with an escape-hatch flag called &lt;code&gt;--allow-no-td&lt;/code&gt; and a matching environment override. The script it named contained neither string.&lt;/p&gt;

&lt;p&gt;The second described a checklist item the planning agent was supposed to add when planning new work. That agent had never been told to add it. By the time I found this, a second rule described its own gate as sitting "parallel to the IaC checkbox," which is to say parallel to nothing.&lt;/p&gt;

&lt;p&gt;The third told developers to scaffold their first failing test with &lt;code&gt;npm run new-test&lt;/code&gt;. No repository in the portfolio had a &lt;code&gt;new-test&lt;/code&gt; script in any manifest.&lt;/p&gt;

&lt;p&gt;The detail is what makes these dangerous rather than merely aspirational. A vague aspiration reads as an aspiration and gets treated like one. A documented flag name reads as a flag, and the reader's next move is to type it. I'd written three specifications convincing enough that an agent following them would have believed it was working inside a system of gates, and would have been alone.&lt;/p&gt;

&lt;p&gt;This is the failure mode where documentation asserts coverage that never existed. Prose can't catch it, because prose is where it lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cannot do, said plainly
&lt;/h2&gt;

&lt;p&gt;The check reads three kinds of claim: a named npm script, a runnable path, and a command-line flag attributed to a command I own. It doesn't read prose for meaning.&lt;/p&gt;

&lt;p&gt;So of the three defects that motivated it, it catches the first and the third. It doesn't catch the second. A checklist item described only in sentences names nothing runnable, so there's nothing to resolve and nothing to fail on. A rule can still promise a gate in English and walk past every check I have. What it can no longer do is name a runnable thing that isn't there.&lt;/p&gt;

&lt;p&gt;I'm spending this many words on the boundary deliberately. Overstating the coverage would be precisely the defect the check exists to catch, and I'd rather be tedious about the limit than publish a post that commits its own subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it quiet enough to survive
&lt;/h2&gt;

&lt;p&gt;The engineering that mattered was not detection. It was suppression. A check that cries wolf gets disabled, and a disabled check asserts exactly as much as that comment does, which is nothing.&lt;/p&gt;

&lt;p&gt;Flags were the hard part. &lt;code&gt;gh pr checks --watch&lt;/code&gt; and &lt;code&gt;git ls-remote --heads&lt;/code&gt; are flags on other people's tools and I'm not the one who implements them. An early version attributed any flag to whatever script happened to be named in the same section, which produced eight confident reports about flags that were never mine. So attribution runs command-first: if the surrounding text invokes a script I own, the flag is checked against that script; if it invokes somebody else's tool, there's no claim at all. But it can't be command-only, because a bare flag written in a sentence has no command attached, and that's exactly the shape the real defect took. &lt;code&gt;--allow-no-td&lt;/code&gt; sat four lines below the script that was supposed to own it, in prose. The section-level fallback is load-bearing.&lt;/p&gt;

&lt;p&gt;Environment variables are off by default. Operator-setup sections mix mine with everyone else's and nothing in a variable name tells you whose it is. Gating on them produced eleven false reports, and eleven false reports is how you teach someone to ignore a check permanently. Nothing is lost, because every environment override in these designs has a paired flag and the flag is checked. The original fabricated variable got caught anyway, through its twin.&lt;/p&gt;

&lt;p&gt;Paths are narrow: runnable files under three directories. An earlier draft included documentation directories and reported seventy-one unbacked paths, nearly all of them either stale cross-links between rule files or claims about a consumer's tree rather than the publisher's. Both are real problems. Neither is this one, and folding them in would bury the signal under a backlog nobody triages.&lt;/p&gt;

&lt;p&gt;I re-ran the tuned version against the authoring repository while writing this. A hundred and twenty claims across forty-five files, zero unbacked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the two were still there
&lt;/h2&gt;

&lt;p&gt;So the publisher is clean and the consumer isn't. There are two reasons, and the second is the one worth your time.&lt;/p&gt;

&lt;p&gt;The first is that the corpus is authored in one repository and mostly &lt;em&gt;describes&lt;/em&gt; the others. &lt;code&gt;npm run dev&lt;/code&gt; resolves in a product repository and nowhere near the publisher. So the check runs in two modes, and running the consumer mode at the publisher reports seven unbacked claims that are all false: &lt;code&gt;dev&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt;, &lt;code&gt;typecheck&lt;/code&gt;. Real scripts, in other repositories.&lt;/p&gt;

&lt;p&gt;I wrote that asymmetry down when I built it, along with its consequence, which I'm going to quote because I was right and it did me no good whatsoever: at the publisher, a fabricated &lt;code&gt;npm run new-test&lt;/code&gt; is indistinguishable from a real &lt;code&gt;npm run dev&lt;/code&gt;, and only a consumer can tell them apart.&lt;/p&gt;

&lt;p&gt;I had the correct theory of where this check needed to run, and I ran it in the other place.&lt;/p&gt;

&lt;p&gt;The second reason is worse and more interesting. A failing claim only ever has two honest resolutions: build the thing, or stop saying you did. Which one is right depends on whether the gate was worth having, and deleting the sentence is only the answer when the mechanism genuinely isn't coming. Delete prose to turn a check green and you haven't fixed anything, you've just moved the lie somewhere the check can't see it.&lt;/p&gt;

&lt;p&gt;So I repaired the three claims three different ways. One gate was worth having and I built it, though not where I'd said it was: it lives in the planning agent's own procedure rather than in the script the rule had named, and the prose changed to describe where the gate actually is. One I resolved the other way. And where the scaffolding command used to be, the upstream file now carries an explicit correction saying in as many words that the command was specified and never built, that no manifest anywhere has it, and that it had been written up as though it shipped. The claim is gone and the record of it isn't, which is the only version of deletion I trust. That correction is sitting there today.&lt;/p&gt;

&lt;p&gt;It's sitting in the file that &lt;em&gt;replaced&lt;/em&gt; the file the claim was written in.&lt;/p&gt;

&lt;p&gt;A while back I consolidated thirty-three rule documents down to eight. The consolidation created the new files and distributed them everywhere. It didn't delete the old ones. My distribution manifest lists fourteen sibling documents; the consumer repository has nineteen sitting in that folder. Five are pre-consolidation ancestors: unowned, undelivered, never updated again, and completely indistinguishable from the live ones to anything that reads the directory. Including my agents.&lt;/p&gt;

&lt;p&gt;So the correction landed on the descendant. The ancestor kept the original claim, kept shipping it, and kept reading as authoritative. Upstream: fourteen files, no orphans, zero unbacked. One repository over: nineteen files, five of them ghosts, and inside one of the ghosts, verbatim, the fabricated flag and the fabricated command.&lt;/p&gt;

&lt;p&gt;I checked both by hand before writing this. The script contains neither the flag nor its environment variable. No manifest in that tree has a &lt;code&gt;new-test&lt;/code&gt; script.&lt;/p&gt;

&lt;p&gt;The third unbacked claim, for completeness, is the check being wrong. A rule describes CI running &lt;code&gt;npm run typecheck --if-present&lt;/code&gt;, and that repository has no &lt;code&gt;typecheck&lt;/code&gt; script. But &lt;code&gt;--if-present&lt;/code&gt; is the idiom that means absence is fine; the claim never asserted the script exists. My check can't read that qualifier, so it flagged a gate behaving exactly as designed. Two real, one false. I'd rather print three and explain than round down to the number that flatters me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The step after noticing
&lt;/h2&gt;

&lt;p&gt;The check was right. The design note about publishers and consumers was right. The repair was right, and thorough, and honest about itself.&lt;/p&gt;

&lt;p&gt;What failed was distribution, which is the second Sunday running that I've written that sentence in a different costume. Last week it was a retrospective whose findings were real and went nowhere. The noticing has never been my weak link. The step after the noticing is where I have no gate, no counter, and apparently no attention left over, and it keeps being the part that costs me.&lt;/p&gt;

&lt;p&gt;There's a version of this post where I fix the orphans tonight and write all of this in the past tense. I'm not writing that one. They're the evidence, and they'll be a line on my roadmap tomorrow morning instead of a quiet commit tonight.&lt;/p&gt;

&lt;p&gt;The comment you never turned into a check is one failure. The check you wrote and never pointed anywhere is the same failure wearing better clothes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The cost gate: infrastructure spend as a merge requirement rather than a monthly surprise, and what it's like to have a pull request blocked by a number instead of a test.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>codequality</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>My retro proposed three fixes and built none of them. Its health check read fine the whole time.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Mon, 24 Aug 2026 02:02:57 +0000</pubDate>
      <link>https://dev.to/earnestamateur/my-retro-proposed-three-fixes-and-built-none-of-them-its-health-check-read-fine-the-whole-time-3gcf</link>
      <guid>https://dev.to/earnestamateur/my-retro-proposed-three-fixes-and-built-none-of-them-its-health-check-read-fine-the-whole-time-3gcf</guid>
      <description>&lt;p&gt;Last Sunday I published &lt;a href="https://lionshead.digital/notes/the-fleet-that-builds-my-backlog" rel="noopener noreferrer"&gt;a post about the agent fleet that builds my backlog&lt;/a&gt;. It ended with a section called "What I have not run," in which I said the retrospective pass was designed, was accepted, and as of that morning was code that had never run. The next sentence said the lenses and the trigger arithmetic had shipped into my standards a few hours before the post did.&lt;/p&gt;

&lt;p&gt;Both halves were wrong by the time anyone read them.&lt;/p&gt;

&lt;p&gt;The first retrospective merged at 15:30:35 Eastern that afternoon. That is twenty-nine minutes before the timestamp on the post claiming it had never run, and five hours and forty minutes before I actually pressed commit. The machinery had shipped two days earlier, not a few hours. And the post closed by asking whether specialist lenses reading a batch of merged stories at once would find the drift the design promises, or whether that was one more thing I believed because I wrote it down. The answer had been sitting in a merged file on my own main branch since mid-afternoon.&lt;/p&gt;

&lt;p&gt;I am not going to edit that post. Originals stay as they were. But it is a strange way to learn that I was writing about my own system from memory instead of from it.&lt;/p&gt;

&lt;p&gt;So: it ran. Twice, over fifteen merged stories and then seven. It found real things. And then it found something worse than anything on either board, which is that everything it produced the first time had quietly gone nowhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the thing is
&lt;/h2&gt;

&lt;p&gt;After a batch of stories merges, eight specialist lenses read the whole batch at once. Architecture, integration, testing, UX, product scope, process, documentation, business. Each is a separate agent with a written charter, each looks only for the class of problem that lens exists to notice, and each must either file a finding or explicitly pass. Then everything they file lands on one board and gets sorted into work to do now, work for the roadmap, or things that are permanently out of scope.&lt;/p&gt;

&lt;p&gt;The premise is one I still believe. Individual code review is at the wrong altitude for a whole class of defect. Three stories can each invent a slightly different way to do one job, and every review passes, because from inside any one story there is nothing to compare against. You only see it by looking at ten at once.&lt;/p&gt;

&lt;p&gt;It is generative and never gating. It runs after the work is merged, and the only things it can produce are new work and durable learnings. It cannot block anything.&lt;/p&gt;

&lt;p&gt;That turns out to matter more than I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it caught
&lt;/h2&gt;

&lt;p&gt;It works. I want to be clear about that before I spend the rest of the post on how it failed, because the findings are real and I would not have gotten them any other way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One authorization boundary, expressed three ways.&lt;/strong&gt; The free-versus-paid line in the product lives in a &lt;code&gt;redact ? null : ...&lt;/code&gt; ternary in the GraphQL resolver, in a &lt;code&gt;synthesisAllowed&lt;/code&gt; early return for the derived facts, and in independent tier reads inside two REST mirrors. One story drew three blocking review findings, all of which its own reviewer characterized as one family: a field derived from the redacted blob, or an action gated on it, that the decision table missed. A fourth was found live after the code review passed. The defect was never in a mechanism. It was in the gaps between three, and no single review sits where the gaps are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A route is reachable only if three registries agree.&lt;/strong&gt; A page in this app exists for a user only if it appears in the nav array, has an entry in the sidebar icon map, and ships a segment layout that mounts the app shell. Four merged stories touched the nav registries. One satisfied both nav halves and shipped no layout, so clicking through to Purchases made the entire navigation disappear on the hub that story exists to anchor. Four more routes were still sitting in that third state when the retro ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test that was empty by construction.&lt;/strong&gt; One story's acceptance suite built a recorder sink, called the function under test without wiring the recorder, and asserted the sink was empty. It was empty because nothing could ever have written to it. That assertion carried an acceptance guarantee and could not have failed for any change to production code. On a sibling story with the same shape, deleting an entire acceptance loop left 58 suites and 1,658 tests green.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The milestone shipped its ends and not its middle.&lt;/strong&gt; The active milestone's own sentence is that a stranger can find a county, see what intelligence exists for it, pay, and receive a bundle. The first batch shipped find and see. The second batch also did not ship pay. The two commerce stories were priority zero and ready for both batches and built in neither. Nothing went wrong to cause that. Every pull was defensible in isolation, and those two cards were simply never next when a slot freed. The fabric is very good at building what is on the board and structurally incapable of noticing that the board stopped describing the plan.&lt;/p&gt;

&lt;p&gt;That last one is the finding I would most want a human to have caught, and no human did.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened to them
&lt;/h2&gt;

&lt;p&gt;The first retro adjudicated three of its findings to stubs, which is the strongest disposition it has. A stub is a scoped piece of work with a title, a rationale, and a sketch, ready to become a card.&lt;/p&gt;

&lt;p&gt;Two days later the second retro opened with this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The 2026-08-16 board proposed three stubs. None were built.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two of those three classes recurred in the very next batch. The third is better than that. It had proposed a single reachability check that would supersede the existing icon invariant and absorb the check a queued story was already going to write, and it warned in as many words that otherwise there would be "two checks that have to agree about the same population."&lt;/p&gt;

&lt;p&gt;One story shipped its own check. Another shipped a third. There are now three separate tests walking the same route population, which must all agree with each other, which is precisely the class the stub existed to eliminate and precisely the outcome it predicted in writing.&lt;/p&gt;

&lt;p&gt;Nothing failed. No step errored. The proposals were made, written to a board, committed to the repository, and then the next run started and nobody read them. A ceremony whose only outputs are proposals and learnings is worth exactly what happens to its proposals, and what happened to mine was nothing.&lt;/p&gt;

&lt;p&gt;This is where being generative and never gating stops being a design virtue. I chose that property deliberately, because a retrospective that can block a merge becomes a thing people route around. What I did not think through is that it leaves nothing at all standing between a proposal and the floor. Every other quality mechanism I have gets its force from being able to refuse something. This one had no teeth by construction, and I never asked what would give it any.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gauge that said this was fine
&lt;/h2&gt;

&lt;p&gt;Here is the part that made me sit back.&lt;/p&gt;

&lt;p&gt;The retro instruments its own health. It was designed with the obvious failure mode in mind: that charters accumulate observations forever and never convert any of them into a real check or a real rule, so the ceremony slowly turns into a diary. So it tracks two numbers. How many standing observations are held across the eight charters, and how many have ever graduated out into automation or process.&lt;/p&gt;

&lt;p&gt;After the second run those numbers were nine and four. That is a healthy ratio. That is the ratio you want. The retro's own report says so, in the section named for the rot it exists to detect.&lt;/p&gt;

&lt;p&gt;Both numbers were true. The whole time they were true, every stub the ceremony had produced was being dropped on the floor. Stubs proposed versus stubs ingested was three and zero, and no instrument anywhere in the system was watching that pair.&lt;/p&gt;

&lt;p&gt;I did not build a broken gauge. I built a correct gauge pointed at the wrong thing, which is worse, because a broken gauge eventually reads implausible and a correct one never does. Nine and four looks like a system working. It is what a system working looks like. It is also what this looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counter that was counting in another directory
&lt;/h2&gt;

&lt;p&gt;Then I went looking, the way &lt;a href="https://lionshead.digital/notes/green-is-not-evidence" rel="noopener noreferrer"&gt;I did last month&lt;/a&gt;, and found the same shape twice more in the same week.&lt;/p&gt;

&lt;p&gt;The retro is not supposed to fire after every batch. Small batches are supposed to accumulate: runs of two and two and two should add up and fire one retrospective over all six stories. That accumulation is the mechanism, written into the design document, and it is the thing that makes the compounding claim true rather than aspirational.&lt;/p&gt;

&lt;p&gt;The accumulator kept its state in a file resolved against the current working directory. Every session of the fleet runs from a fresh git worktree. So every run opened a brand new counter at zero, found nothing in it, and reported its source as &lt;code&gt;absent&lt;/code&gt;, its verdict as &lt;code&gt;defer&lt;/code&gt;, its reason as below the floor.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;absent&lt;/code&gt; reads as "this is the first run." It actually meant "the state you want is in a different directory."&lt;/p&gt;

&lt;p&gt;Measured against real history: three runs, two merged stories each, from three real worktrees. All three read &lt;code&gt;absent&lt;/code&gt;. Six stories, zero retrospectives. The cross-run accumulator is the exact mechanism the design specifies, and worktree-local state defeated the design it implements, silently, for as long as it existed.&lt;/p&gt;

&lt;p&gt;This is why the state file on my machine still says one retrospective has happened. Two have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that was missing, not red
&lt;/h2&gt;

&lt;p&gt;The third one came out of the second retro itself, and it is the same sentence again in a different costume.&lt;/p&gt;

&lt;p&gt;A pull request came up for merge with every check green. One required check, the one that runs the tests, was not green and was not red. It was not there. The fan-in job that reports it had never been created, so the context simply did not exist on that pull request. Every visible signal said ready. The pull request was structurally unmergeable.&lt;/p&gt;

&lt;p&gt;Anything reading that pull request for failures would have found none, because there were none. The absence of a failing test run and the absence of a test run are the same picture. The same shape had already hidden never-run tests on two separate stories, in both cases while the developer agent reported them passing locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What these three have in common
&lt;/h2&gt;

&lt;p&gt;A check that isn't running looks exactly like a check that's passing. I wrote that a month ago and thought I had learned it.&lt;/p&gt;

&lt;p&gt;I had learned it about checks. I had not learned it about counters, and a counter is worse, because a check at least has a job that a human can go look at. A counter hands you a number, and a number carries no information about whether anything was measured to produce it. Zero findings and zero looks render identically. Nine and four is arithmetic over the wrong pair. &lt;code&gt;absent&lt;/code&gt; is the honest report of an empty file and a completely dishonest report of the situation.&lt;/p&gt;

&lt;p&gt;Every one of these three had a defensible reason to report what it reported. None of them was a bug in the sense of doing something other than what it was written to do. The rot check counted what it was told to count. The accumulator faithfully read the file at the path it was given. The merge check correctly found no failures.&lt;/p&gt;

&lt;p&gt;The thing they share is that each of them collapsed two states that needed to stay apart. Nothing wrong and nothing looked. Zero and unmeasured. Not failing and not present. In all three cases the collapsed pair renders as the good one.&lt;/p&gt;

&lt;p&gt;So the rule I am taking out of this, which is narrower and more useful than anything I would have written down in advance: any counter that can read zero has to be able to distinguish zero from unmeasured, and has to say which one it means. Not as a nicety. As the primary output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;p&gt;Two things, and only one of them came from a retrospective.&lt;/p&gt;

&lt;p&gt;The reconciliation script exists. It parses the newest retro board, extracts every stub, searches for a card carrying that stub's identifier, and reports the ones with no card behind them. It runs as a precondition before the fleet starts a batch, and again before a retro is allowed to open, so a retrospective now cannot begin without first showing what its predecessor dropped. It reports rather than files, deliberately: I stay the valve on what becomes work.&lt;/p&gt;

&lt;p&gt;The accumulator resolves its state against the git common directory, which every linked worktree of a clone reports as the same path. It also learned a legacy adoption path, so that finding an old worktree-local file and adopting it reports as a migration rather than as silence, and &lt;code&gt;absent&lt;/code&gt; now means genuinely absent. Fifteen new tests, built against real git repositories with real linked worktrees, because the defect was entirely about what git answers from inside a worktree, and a stubbed test would only have asserted the belief that produced the bug. Against a mutant that reverts the path resolution and changes nothing else, nine of the fifteen fail.&lt;/p&gt;

&lt;p&gt;That second one is not a retro finding. I found it separately, which is its own small comment on the state of the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has not
&lt;/h2&gt;

&lt;p&gt;I had a paragraph here claiming two more of these had landed. Before publishing I went and grepped for them, because a post about proposals evaporating is a bad place to assert one landed on vibes.&lt;/p&gt;

&lt;p&gt;The second retrospective proposed five stubs. Five days later, one is built. It is the one whose entire purpose is to report stubs that never got built.&lt;/p&gt;

&lt;p&gt;The merge gate is still the prototype written during the run; there is no script in the repository. The mutation-evidence rule, which would require any assertion carrying an acceptance guarantee to be recorded as observed failing against a deliberately broken tree, is not in the rules file. Neither is the one-sentence rule about how a brief may describe an in-flight sibling. The milestone verb report is not in the fleet's preconditions.&lt;/p&gt;

&lt;p&gt;So the honest count is one of five, and I only know that because I checked by hand, five days after the mechanism that is supposed to tell me had itself shipped, and before it has had a batch to run against. That is better than three and zero. It is not a solved problem, and I would rather print the number than round it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I run it again
&lt;/h2&gt;

&lt;p&gt;Yes, and the honest reason is uncomfortable. The retrospective's most valuable output in two runs was not any of its findings. It was the second run telling me the first run's findings had gone nowhere, which is a thing only a repeated ceremony can tell you, and which I would never have discovered by making the individual findings better.&lt;/p&gt;

&lt;p&gt;The compounding claim from last week is still a claim. Two runs is not a trend. What batch ten teaches batch eleven is still something I have written down rather than something I have watched. But I know one more thing now than I did, which is that the loop's weakest link was never the quality of what it noticed. It was the step after noticing, the one with no gate on it and no counter watching it, and I had spent all of my design attention on the noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The invariant check: a name for the class of assertion that pins a pairing rather than a behavior, and the observation that a prose warning about a hazard is what a careful person writes at the exact moment they had everything in hand to write the check instead.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>automation</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>I handed my backlog to an agent fleet for ten days. Three of its safeguards were lying to me.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Mon, 10 Aug 2026 10:27:41 +0000</pubDate>
      <link>https://dev.to/earnestamateur/i-handed-my-backlog-to-an-agent-fleet-for-ten-days-three-of-its-safeguards-were-lying-to-me-4hg4</link>
      <guid>https://dev.to/earnestamateur/i-handed-my-backlog-to-an-agent-fleet-for-ten-days-three-of-its-safeguards-were-lying-to-me-4hg4</guid>
      <description>&lt;p&gt;I have a launch with a date that does not move. I also have a job between eight and five, a two year old, and a four month old. Everything I build, I build around those. So the binding constraint is not taste or architecture. It is how many stories I can carry from "ready" to "merged" in the hours that are left, which for the next couple of months is most of the hours I am not at work or holding a baby.&lt;/p&gt;

&lt;p&gt;The obvious lever is to stop being in the loop for every story. Ten days ago I wired up a fleet that pulls from my story queue, builds each story, reviews it, and merges it. Then I pointed it at the product with the deadline and let it run.&lt;/p&gt;

&lt;p&gt;It worked, in the sense that 503 commits landed across 168 pull requests in the ten days that followed.&lt;/p&gt;

&lt;p&gt;I should say plainly what that means, because it is the fact most likely to be misread. I did not write any of them. Not one line of that product's code has been hand-written or hand-edited by me in months. I instruct, and I review. Claude writes. Every commit below is an agent's work, including the three failures.&lt;/p&gt;

&lt;p&gt;It also lied to me three times, in three different ways, and not one of the lies announced itself. This is a post about the three. I have written two posts in a row about the gap between a check existing and a check running: &lt;a href="https://lionshead.digital/notes/green-is-not-evidence" rel="noopener noreferrer"&gt;green is not evidence&lt;/a&gt;, then &lt;a href="https://lionshead.digital/notes/make-it-fail-on-purpose" rel="noopener noreferrer"&gt;make it fail on purpose&lt;/a&gt;. I did not expect to find the same disease in a system I built with that lesson still fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the machine
&lt;/h2&gt;

&lt;p&gt;Two roles, deliberately unequal.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;planner&lt;/strong&gt; is the expensive one, and the only part allowed to hold the whole graph: what is in the backlog, what the written strategy says matters, what depends on what, which stories produce something another one consumes. It fills the ready queue itself rather than waiting for me to hand-pick the next card, because a machine that needs me to curate its input every cycle has relocated the work rather than removed it. It behaves like a scrum master rather than a filter: when the roadmap needs a story that does not exist, it writes the stub.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;executor&lt;/strong&gt; is the cheap one, and it is deliberately dumb. It carries stories through create, build, review, merge. It does not re-derive dependencies. It does not resolve ambiguity. Every unclear thing is an escalation rather than a judgment call, either back to the planner or to me. The rule I wrote at the top of its instructions is the only one that really matters: cheap but humble, never cheap but guessing.&lt;/p&gt;

&lt;p&gt;Three things about how it spends money, because they are what people ask about and they are the reason it can run unattended at all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It picks the model per step.&lt;/strong&gt; Spec-writing and mechanical work go to a cheaper tier, implementation to a stronger one. The code reviewer is pinned to the top tier and nothing is permitted to cheapen it, ever, because a cheap reviewer defeats the only reason any of this is allowed to merge without me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It watches its own spend.&lt;/strong&gt; My usage telemetry exports to Grafana, the fleet reads it back, and it compares this week's burn against a target line. When it is running hot it cuts its own concurrency first, before it touches anything else. It throttles itself so that I do not have to notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works the gaps between stories, not just the stories.&lt;/strong&gt; Every story declares what it produces and what it consumes, and the fleet checks that anything being consumed has a producer that already landed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am going to write the full build-out as its own post, because the mechanism deserves more room than this one can give it. What matters here is that third bullet, because that is precisely where the third lie turns out to live.&lt;/p&gt;

&lt;p&gt;Around that sit the safeguards, and the safeguards are the whole reason I was willing to let it merge anything without reading it first. Some of them police what the fleet is allowed to start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;scope gate&lt;/strong&gt;, so it only builds work inside the milestone I am currently shipping: the card that serves the launch, not the most interesting one.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;cap on work in flight&lt;/strong&gt;, so a bad pattern can only contaminate a few stories before I see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency gating&lt;/strong&gt;, so a story does not start until the things it depends on have landed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Others police what it is allowed to finish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No self-review.&lt;/strong&gt; The thing that reviews the code is a different instance than the thing that wrote it. Non-negotiable, and the reason review is a separate spawn rather than a continuation.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;live design review&lt;/strong&gt; on any story that touches a real user-facing surface. A reviewer drives the running preview deployment, looks at the rendered result in light and dark, checks the empty and error states, and returns a verdict with evidence.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;human-eyes lane.&lt;/strong&gt; Anything touching auth, billing, money movement, schema migrations, or the merge gates themselves gets built and driven to green automatically and then parked, unmerged, for me to click.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one polices the whole run: the &lt;strong&gt;budget throttle&lt;/strong&gt; above.&lt;/p&gt;

&lt;p&gt;That is the design. Three of those safeguards were lying, and a safeguard lies by reporting a protection it is not providing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am not going to claim
&lt;/h2&gt;

&lt;p&gt;I went and counted, instead of trusting my memory.&lt;/p&gt;

&lt;p&gt;The last full week before the fleet existed: 403 commits. The first full week after it: 401. Flat. If you were hoping for a throughput multiplier, it is not in this data, and I am not going to manufacture one out of a chart that does not show it.&lt;/p&gt;

&lt;p&gt;One thing makes that flat line more interesting than it looks. I had been out of work for four months. My first day back at an eight-to-five was July 29th, and the fleet landed on the 30th. So the 403 week was a week when this product was my full-time job, and the 401 week was the first week it emphatically was not.&lt;/p&gt;

&lt;p&gt;That is the honest claim. Not that the fleet made me faster. That it held my pace steady through losing most of my waking day to something else, which for the next couple of months is the only result I actually need.&lt;/p&gt;

&lt;p&gt;What changed underneath the flat line is what a single unit of work costs me.&lt;/p&gt;

&lt;p&gt;Before, every story meant opening three sessions by hand: one to write the spec, one to implement it, one to review it. Dozens of times a week. The cost was never the typing. It was that each one required me to stop what I was doing, decide it was time, and start something. That is a tax on attention, and attention is the resource I am actually short of.&lt;/p&gt;

&lt;p&gt;The fleet instantiates all three the moment they are due, in order, without asking. I read outcomes instead of driving steps. The same output for a fraction of my attention is a trade I will take every single time, and it is the honest version of what happened here. It is also not the version that fits in a headline, which is exactly why I want it stated before the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie one: the instructions the fleet was running were not the instructions I had written
&lt;/h2&gt;

&lt;p&gt;The fleet's behavior lives in instruction files. Those files live in a company module that gets distributed to every product repo, and the copy the agent runtime actually loads is generated separately, into a different directory.&lt;/p&gt;

&lt;p&gt;Except it was not generated. Nothing generated it. The copies the fleet was really loading had been made by hand months earlier, and they had drifted thousands of bytes behind the source I kept carefully editing.&lt;/p&gt;

&lt;p&gt;So a real batch ran against an executor missing two of the safeguards I listed above as though they were operating: the scope gate and the live design review. Neither fired, because neither was in the file it was reading. The planner's copy was worse, missing four of its sections and its entire milestone model.&lt;/p&gt;

&lt;p&gt;This is a known hazard in the underlying tooling, not a clever novel bug of mine. The same class of gap has been reported upstream more than once: skills installed into a custom module's own directory without the registration that makes them loadable (&lt;a href="https://github.com/bmad-code-org/BMAD-METHOD/issues/2107" rel="noopener noreferrer"&gt;BMAD-METHOD issue #2107&lt;/a&gt;), and custom-module agents installed without the commands that make them invocable (&lt;a href="https://github.com/bmad-code-org/BMAD-METHOD/issues/1131" rel="noopener noreferrer"&gt;issue #1131&lt;/a&gt;). Both are closed now, so I am not pointing at an open ticket and calling it my excuse. I am pointing at prior art: this is a well-worn hole, and my module fell in it.&lt;/p&gt;

&lt;p&gt;What kept me in the hole was not the tooling. The instruction file's own header stated, in confident prose, that the installer generated that copy "exactly as it does" for the built-in modules. That sentence sat at the top of the file for months. It was wrong. A document asserting that a mechanism is wired, sitting inside the mechanism it is wrong about, is the purest version of the failure I wrote up two weeks ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie two: the gate that should have caught lie one read silence as approval
&lt;/h2&gt;

&lt;p&gt;Two stories touching user-facing surfaces merged with no design review at all. Not a review that passed when it should have failed. No review.&lt;/p&gt;

&lt;p&gt;That is the same incident as lie one, viewed from the other side. The drift explains why the reviewer never ran. It does not explain why nothing noticed. The gate looked for a rejection, found none, and let the story through, which means a missing reviewer and a clean pass produced identical output.&lt;/p&gt;

&lt;p&gt;Two independent bugs stacked on one control: the reviewer was absent, and the gate was built to be silent about absence. Either one alone is survivable. Together they are indistinguishable from working. A gate that only blocks when it produces a rejection is not a gate, because the most common way for a gate to fail is not to run at all.&lt;/p&gt;

&lt;p&gt;Underneath both sits a third temptation, and it is the one I would warn you about most: letting the code reviewer "also cover the UX." It cannot. A code reviewer reads class names and token references off a diff. A broken layout, an unreadable dark mode, an empty state that renders as a blank rectangle: none of those are visible in a diff. Folding a check into a reviewer that structurally cannot perform it is not a cheaper version of the check. It is the absence of the check, wearing its name.&lt;/p&gt;

&lt;p&gt;The rule now is one line, and it is the rule from two posts ago in a new costume. Exactly two artifacts satisfy that gate: a recorded pass with live evidence attached, or my explicit written waiver. Anything else, silence very much included, is a block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lie three: a merge that made another story's premise false, with nothing to notice it
&lt;/h2&gt;

&lt;p&gt;The first two came down to a control that was missing, and a gate that could not tell it was missing. This one was there, ran exactly as designed, and reported a clean board anyway. It is the one I find genuinely hard, and the one I would not have predicted.&lt;/p&gt;

&lt;p&gt;The fleet watches the seams between stories by checking dependencies. When a story merges, it asks what that merge unblocked. That is a set-membership question and it is cheap, which is why a mechanical executor is allowed to do it.&lt;/p&gt;

&lt;p&gt;But a merge can break another story without touching a single dependency edge. A design document is full of statements about the world: this thing is not wired up yet, nothing reads this yet, this path is inert. Every one of those was true when it was written. A merge can turn any of them false while the story that rests on it is still being built, and nothing in the dependency graph moves at all.&lt;/p&gt;

&lt;p&gt;That happened. One story merged and quietly falsified a premise that another in-flight story's design rested on. The dependency check saw a clean board and kept pulling. A human caught it by reading, which is not a mechanism I can schedule.&lt;/p&gt;

&lt;p&gt;The fix is embarrassingly small, which is the good kind of small. After a merge, take the names of the surfaces that the merge touched, and grep the in-flight branches' design docs and changelogs for those names. Do not evaluate whether the claim still holds in spirit, because that is judgment and the cheap executor is not allowed to have any. Report that an in-flight document names something this merge just changed, and let the planner decide. One command, and it would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not built
&lt;/h2&gt;

&lt;p&gt;I have ended the last few of these with an honest list of what I have not finished, because a half-built system that thinks it is finished is worse than one that knows it is halfway. Here is the list for this one.&lt;/p&gt;

&lt;p&gt;The design calls for a retrospective pass after each batch: a set of specialist lenses that look at ten merged stories together and catch the drift that no single story's review could possibly see, because the defect only exists in the space between them. Three stories that each independently invented a slightly different pattern for one job, and each passed review on its own terms. A feature that is now complete and undocumented because no single story owned the seam. That review is written down, it is accepted, and it does not exist. Only the planner and the executor are real.&lt;/p&gt;

&lt;p&gt;Which means the compounding half of this system, the half where what batch ten teaches gets fed back so batch eleven starts from a higher floor, is currently a document. The throughput half is running. The learning half is a plan.&lt;/p&gt;

&lt;p&gt;There is one detail I cannot leave out. That unbuilt retrospective specifies six lenses, and one of them is a documentation lens whose stated job includes mining these posts out of what a run just taught. This post is that lens's output, produced by hand, because the thing that was supposed to produce it has not been built. I am not sure whether that is funny or just accurate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The fleet spends my weekly model allowance, which is why the throttle exists and why it cuts parallelism before it does anything else. What it may never do is cheapen the reviewer.&lt;/p&gt;

&lt;p&gt;The real cost is subtler. Every safeguard I listed is a claim about a system I could not watch directly, and I was making those claims to myself, in documentation, and believing them. Three of them were reporting a protection they were not providing, and I did not learn that from an alert. I learned it from reading, from a batch behaving oddly, and in one case from a person noticing something no check was positioned to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;The honest summary of ten days is not "agents built my product." It is that agents built my product, and the part of the system I trusted most, the safeguards, was the part with the highest rate of quiet failure.&lt;/p&gt;

&lt;p&gt;Which follows, if you sit with it. The feature code announces its failures, because a broken feature is visible the moment someone uses it. The safeguards are the only components whose success looks exactly like doing nothing, so they are the only ones that can fail for months in complete silence. I have now found that same shape in a Vale rule, in a CI check, and in an agent fleet, and I no longer think it is a coincidence about any of those three things. It is a property of anything whose job is to sit quietly and not fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The unbuilt half. I am going to build the retrospective pass and run it against a real batch. Then I will write up whether specialist lenses reading ten merged stories at once find the drift the design promises, or whether that is a fourth thing I believed because I wrote it down.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Killing the long-lived tokens in my CI, one exchange at a time</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Mon, 03 Aug 2026 02:26:14 +0000</pubDate>
      <link>https://dev.to/earnestamateur/killing-the-long-lived-tokens-in-my-ci-one-exchange-at-a-time-3514</link>
      <guid>https://dev.to/earnestamateur/killing-the-long-lived-tokens-in-my-ci-one-exchange-at-a-time-3514</guid>
      <description>&lt;p&gt;A static API token stored in GitHub Secrets is a breach that has not happened yet. The timer is already running. You do not know how long the fuse is, because a long-lived token's fuse is however long you set it at mint time, which for most people is "never thought about it."&lt;/p&gt;

&lt;p&gt;At enterprise scale a leaked CI token is an incident: rotate it, audit the blast radius, file the postmortem. At solo scale it is the same product-ending event as any other breach. My CI can reach Doppler, which holds every secret every product uses. A token that lets it do that, sitting in a secret store, readable by any workflow, valid for months, is precisely the thing I do not want to own.&lt;/p&gt;

&lt;p&gt;So the goal is to own zero of them. Not "rotate them on a schedule I will forget." Zero. The mechanism that gets you there is OIDC, and the good news is that it is strictly better by default: less to store, less to leak, less to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanism, in one paragraph
&lt;/h2&gt;

&lt;p&gt;Instead of storing a token, the CI job proves who it is at runtime and trades that proof for a token that expires in minutes. GitHub Actions can mint a short-lived OIDC token for a workflow run, signed by GitHub, carrying claims about which repo and which workflow asked for it. Doppler has a matching feature, &lt;a href="https://docs.doppler.com/docs/service-account-identities" rel="noopener noreferrer"&gt;Service Account Identities&lt;/a&gt; (a paid capability, which I will price out in the cost section), that validates such a token against a set of expected claims and hands back a short-lived Doppler session token in exchange. The job posts the GitHub token to &lt;code&gt;POST https://api.doppler.com/v3/auth/oidc&lt;/code&gt; with an identity slug, gets back a session token good for ten minutes to an hour, uses it, and the run ends. Nothing durable is stored anywhere. (I skip Doppler's off-the-shelf &lt;a href="https://github.com/DopplerHQ/secrets-fetch-action" rel="noopener noreferrer"&gt;&lt;code&gt;secrets-fetch-action&lt;/code&gt;&lt;/a&gt;, which only injects fetched secrets as environment variables; my workflows need the raw session token itself, to hand to &lt;code&gt;doppler run&lt;/code&gt; and to Terraform as &lt;code&gt;TF_VAR_doppler_token&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a static service token is the wrong shape
&lt;/h2&gt;

&lt;p&gt;Three properties make a long-lived token structurally bad, and they are the same three that pushed the GitHub side off personal access tokens in an earlier migration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lifetime.&lt;/strong&gt; The token is valid until someone remembers to revoke it. Rotation is a manual ceremony with no calendar attached. The compromise window equals the lifetime, and the lifetime is effectively forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No identity.&lt;/strong&gt; A Doppler service token has a label, not a workload-bound identity. When it is exercised, the audit log names the token, not the caller. A leak shows up as "that token did something," with no provenance about which workflow in which repo actually made the call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No revocation chokepoint per purpose.&lt;/strong&gt; One shared token feeds many consumers. Revoking it kills all of them at once. Granular, per-purpose revocation means minting per-purpose tokens, which nobody does, so in practice you get one big token and an all-or-nothing kill switch.&lt;/p&gt;

&lt;p&gt;OIDC fixes all three at once. The credential lives for minutes, it is bound to a named service-account identity with claims that pin it to a specific repo, and each logical purpose gets its own identity you can disable independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually runs on OIDC today
&lt;/h2&gt;

&lt;p&gt;Here is the concrete state, not the aspiration.&lt;/p&gt;

&lt;p&gt;A word on "per-product" first, because it runs through everything below. Lionshead is a set of small separate repos, one per product, rather than one monorepo. I wrote about that setup and why I chose it in &lt;a href="https://lionshead.digital/notes/the-reconciler" rel="noopener noreferrer"&gt;the reconciler post&lt;/a&gt;. The practical effect here is that most of this machinery is replicated per repo: one identity, one Doppler config, one token scope each, so a compromise in one product cannot reach another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform plan and apply, every product, no stored token.&lt;/strong&gt; Every &lt;code&gt;terraform-plan&lt;/code&gt; (on PRs) and &lt;code&gt;terraform-apply&lt;/code&gt; (on merge to main) authenticates to Doppler by OIDC. The caller declares &lt;code&gt;permissions: id-token: write&lt;/code&gt;, passes a per-product identity slug (for this repo, &lt;code&gt;LIONSHEAD_WEB_DOPPLER_TF_IDENTITY_SLUG&lt;/code&gt;, a GitHub variable), and the reusable workflow does the exchange. The slug is not a secret: it is a stable identifier that is worthless without a GitHub-signed token carrying the right claims, so it lives as a plain repo variable that Terraform itself manages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three named service accounts, not one shared token.&lt;/strong&gt; The setup provisions &lt;code&gt;ci-platform-services-tf&lt;/code&gt; (admin, OIDC, session TTL 3600s, one hour), &lt;code&gt;ci-credential-sync&lt;/code&gt; (OIDC, session TTL 600s, ten minutes, the job that writes org secrets into per-product Doppler configs), and one transitional static-token account (covered below). Each has its own claims and its own independent off switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Terraform-managed identity itself.&lt;/strong&gt; The Doppler provider has a native &lt;code&gt;doppler_service_account_identity&lt;/code&gt; resource with a &lt;code&gt;config_oidc&lt;/code&gt; block (&lt;code&gt;discovery_url&lt;/code&gt;, &lt;code&gt;claims&lt;/code&gt;, &lt;code&gt;ttl_seconds&lt;/code&gt;). The thing that grants OIDC access is itself declared in code and reviewed in a PR, not clicked into a dashboard. The one exception is the very first admin identity, which cannot create itself: that is a one-time manual step in the Doppler dashboard, which I wrote down as the single documented exception it is, so it is not a mystery to future me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A fallback that fails loud, not silent.&lt;/strong&gt; The Terraform callers explicitly pass &lt;code&gt;doppler-token-secret-name: ''&lt;/code&gt;. The reusable used to default that input to &lt;code&gt;DOPPLER_TOKEN&lt;/code&gt;, so if an identity slug ever resolved to empty (org variable deleted, repo forked, a run context that cannot see org variables) and a bare &lt;code&gt;DOPPLER_TOKEN&lt;/code&gt; happened to exist in scope, it would quietly slide back onto the static path. Passing an empty string forces a loud OIDC failure instead of a silent downgrade to the thing I am trying to delete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard that proves it stays gone.&lt;/strong&gt; The old workplace &lt;code&gt;DOPPLER_TOKEN&lt;/code&gt; GitHub org secret was deleted on 2026-06-09. A scheduled audit workflow (&lt;code&gt;security-audit-doppler-token.yml&lt;/code&gt;) watches for its reintroduction, and post-deletion Terraform runs across three products came back green with zero deprecated-static-token warnings, which is the only actual evidence that every Terraform caller is really on OIDC. (If that sounds like a theme, it is the &lt;a href="https://lionshead.digital/notes/make-it-fail-on-purpose" rel="noopener noreferrer"&gt;make-it-fail-on-purpose&lt;/a&gt; rule from the last post: the guard is only worth something because I watched the audit catch a planted token before I trusted it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What still holds a long-lived token
&lt;/h2&gt;

&lt;p&gt;This is the part every "we adopted OIDC" post leaves out. I have not finished. Here is exactly what is still static, and why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The per-product Terraform bridge.&lt;/strong&gt; Per-product Terraform still needs a Doppler token for two things: its own Doppler provider block, and its Vercel-sync REST calls. That second one is worth explaining: those calls register a Doppler-to-Vercel integration, which is what pushes each product's secrets out of Doppler and into that product's Vercel project environment, so the deployed app actually has its config at runtime. Terraform sets that sync up through Vercel's API, and the call authenticates with a Doppler token. An OIDC session token (TTL 3600s, one hour) would expire partway through a long apply, so one service account, &lt;code&gt;ci-per-product-tf-bridge&lt;/code&gt;, keeps a long-lived token. The one mitigation that matters: that token lives only in Doppler's own state and the per-product config it populates. It is never written to a GitHub org secret. It is explicitly labeled transitional; the follow-up is per-product OIDC identities that retire it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Vercel build wrap.&lt;/strong&gt; The &lt;code&gt;vercel-deploy&lt;/code&gt; jobs still wrap &lt;code&gt;vercel build&lt;/code&gt; in &lt;code&gt;doppler run&lt;/code&gt; using a per-product static secret (&lt;code&gt;LIONSHEAD_WEB_DOPPLER_SERVICE_TOKEN_CI&lt;/code&gt;) to inject build-time secrets like the Sentry auth token. This one is a plain GitHub secret and it is next on the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note syndication.&lt;/strong&gt; The workflow that cross-posts these notes to dev.to, Hashnode, and Mastodon uses that same per-product static token to pull the app tokens out of Doppler. The workflow file literally carries a &lt;code&gt;# Migrate to Doppler OIDC&lt;/code&gt; comment, because I wrote the honest TODO in the place I would actually see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Terraform state-backend keys.&lt;/strong&gt; The R2 credentials that read and write remote Terraform state (&lt;code&gt;LIONSHEAD_WEB_TFSTATE_R2_*&lt;/code&gt;) are static repo secrets issued by platform-services. These are S3-style keys, not Doppler tokens, so they are a different migration on a different provider's timeline, but they are static and I am not going to pretend otherwise by leaving them off the list.&lt;/p&gt;

&lt;p&gt;So: the Terraform authorization path is done. The build, deploy, and syndication paths are not. Naming the gap is the point. A half-migrated system that thinks it is finished is worse than one that knows it is halfway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The exchange adds one HTTP call and a few seconds to each job. The provisioning is Terraform I write once and inherit everywhere. The GitHub side is free.&lt;/p&gt;

&lt;p&gt;The Doppler side is not, and I want to be straight about it: Service Accounts and their OIDC identities are a paid feature. They start on Doppler's Team plan, which is $21 per user per month as of this writing, so if you are on the free or Developer tier this specific path is not open to you without upgrading. In my case the marginal cost of the OIDC work was zero, because I was already on the Team plan before this migration for an unrelated reason: a different product had outgrown the free tier's cap on secret syncs. That is luck, not a saving, and I would rather you hear the real number than a "no vendor to pay" line that only holds if you happen to be pre-upgraded.&lt;/p&gt;

&lt;p&gt;Against that cost, what I stop owning is a set of long-lived credentials to my entire secret store, each one a standing invitation I have to remember to revoke. At solo scale I would rather have a mechanism that forgets on my behalf, every single run, than a discipline I have to sustain forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Next in the CI/CD sub-series: path-gated test routing across a five-product portfolio. How one reusable workflow decides which tests to run based on what a PR actually touched, why the path gate is shared but the runner command is deliberately caller-owned, and what breaks when you get that split wrong.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>github</category>
      <category>security</category>
    </item>
    <item>
      <title>How do you know a passing check still works? Make it fail on purpose.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:49:15 +0000</pubDate>
      <link>https://dev.to/earnestamateur/how-do-you-know-a-passing-check-still-works-make-it-fail-on-purpose-24nj</link>
      <guid>https://dev.to/earnestamateur/how-do-you-know-a-passing-check-still-works-make-it-fail-on-purpose-24nj</guid>
      <description>&lt;p&gt;A feature makes money. A check protects it. Those are not the same job, and the difference is why checks rot.&lt;/p&gt;

&lt;p&gt;When you ship a feature, the market tells you fast whether it works. Someone signs up or they don't. A check has no such feedback loop. A check that has quietly stopped working looks exactly like a check that is passing, and nothing outside the build can tell you which one you have.&lt;/p&gt;

&lt;p&gt;At corporate scale a QA team is paid to close that gap. Their whole job is the negative case: break the thing on purpose and confirm the alarm rings. At solo scale there is no such team, and the negative case is the first thing to fall off the list, because it is the work that sits furthest from revenue. Features feel like money. Checks feel like insurance. The trap is believing features are money. Features are money-making. Checks are money-protecting. Skip the second one and you don't notice for months, which is exactly the failure mode I wrote about last time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The count I had been avoiding
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://lionshead.digital/notes/green-is-not-evidence" rel="noopener noreferrer"&gt;Green is not evidence&lt;/a&gt; I found four checks that had never done anything. An em-dash linter whose regex could not match. A security scanner that was never installed. A cost gate that was only ever red. A config file I had documented but never written. The post ended on a rule I was very pleased with: make it fail on purpose. Every gate should have a known-bad fixture, something you can point it at that must fail, because if you have never watched a check fail you do not have a check, you have a decoration that emits green.&lt;/p&gt;

&lt;p&gt;Then I did the obvious thing I had not done. I counted how many of my own gates actually had one.&lt;/p&gt;

&lt;p&gt;The answer was humbling. Across my repos I run several dozen distinct gates. The reusable workflow every repo calls runs nine third-party scanners: Trivy twice, Gitleaks, Checkov, Hadolint, Actionlint, Shellcheck, ZAP, and an Infracost cost gate. Not one of them has a fixture. My content pipeline runs four gates on every note: prose style, spelling, frontmatter schema, link integrity. Not one of them has a fixture. The em-dash rule, the one this whole series keeps circling back to, had none.&lt;/p&gt;

&lt;p&gt;What did have fixtures? Only the checks I had written myself. The little scripts that scan for hardcoded hex colors, or unpinned GitHub Actions, or drift in my shared config files: every one of those has a negative test that feeds it bad input and asserts it goes red. I have a unit-testing habit, and it reaches exactly as far as the code I author. Everything I bought, and every rule I configured inside someone else's tool, I took on faith. The fixture was present wherever I already had the habit, and absent everywhere I was trusting a vendor or my own YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two places I had gone further
&lt;/h2&gt;

&lt;p&gt;Twice, I had wired the negative case into CI itself, so it runs forever instead of once. One is a schema gate for my product metadata: CI points the validator at a deliberately invalid file on every run and fails the build unless the validator rejects it. The other is my design-token scanner, which re-runs its own "this hex literal must be caught" test inside every repo that calls it. Two, out of several dozen. Both, again, on code I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Has any of this ever caught a regression?
&lt;/h2&gt;

&lt;p&gt;Here is the part I have to be honest about, because the whole point of the last post was honesty about what a practice actually catches. Has one of these fixtures ever caught a real regression? A gate that silently broke, and a fixture that screamed?&lt;/p&gt;

&lt;p&gt;No. Not once.&lt;/p&gt;

&lt;p&gt;Every real catch I can point to was something else. The em-dash breakage from last post I found by hand, poking at it, not because a fixture rang. The one genuine vulnerability my pipeline caught recently, a critical CVE in a transitive dependency that showed up the week I was writing the last post, was a live scanner doing its ordinary job, not a canary. And the closest thing I have to a fixture story runs the wrong way: my hex-color scanner once misfired, flagged a pull-request number as if it were a color, and blocked a real merge. I fixed it by adding the negative tests I should have had first. The fixture did not prevent the bug. The missing fixture allowed it, and I added the fixture afterward, as an apology.&lt;/p&gt;

&lt;p&gt;So this is not a victory lap. I am writing down a rule I have mostly not been following.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built one
&lt;/h2&gt;

&lt;p&gt;Then I built the first one properly, on the gate that most deserved it.&lt;/p&gt;

&lt;p&gt;There is now a file in my repo whose only job is to be wrong. It contains a single em dash and a line telling you not to remove it. It never renders and it is not a post. And there is a step in CI that points Vale at that one file and inverts the result: if Vale reports the em dash, the step passes; if Vale comes back clean, the step fails the build and tells me the gate has gone inert. It is about fifteen lines. The em-dash rule had been silently dead once already, for the entire life of the repo, and nothing would have told me if it happened again. Now something will.&lt;/p&gt;

&lt;p&gt;The first time I ran it, I broke the rule on purpose and watched the build go red. That is the only moment in this whole exercise that constitutes actual evidence the check works. Everything before it was faith.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fixture is a second thing that can rot
&lt;/h2&gt;

&lt;p&gt;I want to be clear-eyed about the obvious objection. A known-bad file that quietly stops being known-bad is just more green, one layer down. Adding a fixture and never watching it fail leaves you exactly where you started.&lt;/p&gt;

&lt;p&gt;So the discipline is not "add fixtures." The discipline is: break the thing once, on purpose, watch the alarm ring with your own eyes, and then wire that watching into CI so it repeats without you. The fixture is not the point. The watching is the point. The fixture is just how you make the watching automatic, and cheap enough that you keep doing it after the day you cared.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;So here is the rule, and it is going into my standards where the checklist that opens every story will ask about it. No gate ships without a fixture that proves it can fail. And the smaller, harder half, the one I keep having to relearn: a fixture you have never watched fail is just another green light.&lt;/p&gt;

&lt;p&gt;If you run any checks at all, you almost certainly have one that has never rung. Pick it. Break the thing it is meant to catch. Watch. It takes ninety seconds, and it is the only proof you are ever going to get.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>I audited 2,864 CI checks. Several had never run, and green never told me.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:49:14 +0000</pubDate>
      <link>https://dev.to/earnestamateur/i-audited-2864-ci-checks-several-had-never-run-and-green-never-told-me-1abb</link>
      <guid>https://dev.to/earnestamateur/i-audited-2864-ci-checks-several-had-never-run-and-green-never-told-me-1abb</guid>
      <description>&lt;p&gt;Someone on Hacker News asked a one-line question about &lt;a href="https://lionshead.digital/notes/the-security-checks-in-every-lionshead-pr" rel="noopener noreferrer"&gt;the security checks I run on every PR&lt;/a&gt;: how often do those checks actually hold your PRs back?&lt;/p&gt;

&lt;p&gt;I didn't know. That bothered me more than it should have, so I counted every PR check I have ever run. 2,864 of them, across nine repos.&lt;/p&gt;

&lt;p&gt;The answer to his question turned out to be boring. What I found on the way to it did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring answer
&lt;/h2&gt;

&lt;p&gt;286 of the 2,864 runs failed. About 10%.&lt;/p&gt;

&lt;p&gt;That number is useless on its own, and it took me embarrassingly long to see why. A 10% failure rate sounds like a tax, one PR in ten held up at the door. It isn't, because the failures are not spread out. They arrive in tight three or four day clusters with months of silence between them, and every cluster sits on top of a migration. Late April, when I pulled these checks into a shared reusable workflow: eighteen cost-gate failures over four days while I shook out credential and fetch-depth bugs, then ten weeks of nothing. A second product adopting that workflow in May, with a commit two days later called &lt;code&gt;fix(ci): resolve remaining PR check failures&lt;/code&gt;. Skeleton repos I bootstrapped in July that lit up the same way and then went quiet.&lt;/p&gt;

&lt;p&gt;The repos with the worst failure rates in the whole org are the ones that have only ever been in setup, with no steady state to average against. The worst sits at 57%, and it is the template that the others get cloned from.&lt;/p&gt;

&lt;p&gt;None of that is in the numbers. I only know it because I remember those weeks. Read cold, the dates say the opposite: that my oldest and busiest repo carries the most failures and is therefore the most burdened. It carries the most failures because it is where I did the extraction. The context that told the true story from its inverse lived in my head and nowhere else in the data. That is the benign version of a problem I was about to hit in a much worse form, one where the missing context was not in my head, or anywhere I could reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that didn't fit
&lt;/h2&gt;

&lt;p&gt;I had the answer to the question and could have stopped. What stopped me was a small number that didn't fit.&lt;/p&gt;

&lt;p&gt;I have a Vale rule in my content pipeline called &lt;code&gt;EmDashes&lt;/code&gt;. It is twelve lines long. It is set to &lt;code&gt;level: error&lt;/code&gt;, which fails the build. It exists because I don't write with em dashes, I never have, and I would rather they never reach the site. My drafts run through Claude, which likes them, so the rule is there to strip the ones I miss. That is &lt;a href="https://lionshead.digital/colophon" rel="noopener noreferrer"&gt;stated plainly on the colophon&lt;/a&gt;; the rule is the enforcement.&lt;/p&gt;

&lt;p&gt;The post that started all of this shipped with fifteen em dashes in it.&lt;/p&gt;

&lt;p&gt;Vale ran on that post. Vale passed. On that same file, in that same run, Vale reported fifteen passive-voice alerts and ten weak-word alerts, so it was finding the file, parsing the MDX, and applying my custom styles. It reported zero em dashes in a document containing fifteen of them.&lt;/p&gt;

&lt;p&gt;Here is why. Vale's &lt;code&gt;existence&lt;/code&gt; check compiles a &lt;code&gt;tokens&lt;/code&gt; list into a regex with word boundaries wrapped around it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(?i)(?m)\b(?:—|–)\b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;\b&lt;/code&gt; asserts a boundary between a word character and a non-word character. An em dash is punctuation. There is no word character on either side of the match to anchor the boundary against, so &lt;code&gt;\b&lt;/code&gt; can never be satisfied, so the pattern matches nothing. Ever. Vale has a &lt;code&gt;nonword: true&lt;/code&gt; option that drops the boundaries, and the rule works correctly the moment you add it.&lt;/p&gt;

&lt;p&gt;That rule had sat in my repo, at error level, catching absolutely nothing, since the day I wrote it. And there is no observable difference between that and a rule that works. Both emit silence. Both go green.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was worse than one bug
&lt;/h2&gt;

&lt;p&gt;I fixed the regex, shipped it, felt good about myself, and then went looking for proof that the fix actually gated anything. It didn't.&lt;/p&gt;

&lt;p&gt;Here is the CI step that ran Vale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;vale --no-exit --output=line web/content/notes/ | tee vale-report.txt&lt;/span&gt;
&lt;span class="s"&gt;if grep -E ' error ' vale-report.txt; then&lt;/span&gt;
  &lt;span class="s"&gt;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-exit&lt;/code&gt; forces Vale to exit 0 regardless of what it finds. That was deliberate: print the report first, decide separately. The deciding is that grep. And &lt;code&gt;--output=line&lt;/code&gt; emits &lt;code&gt;path:line:col:check:message&lt;/code&gt;. There is no severity field anywhere in that format. The grep could never match. The two halves cancel each other perfectly: Vale is forbidden from failing the step, and the thing meant to fail the step is searching for a word the format never prints. That job had passed every build it ever ran. It could not have done anything else.&lt;/p&gt;

&lt;p&gt;Then, while testing the fix, Vale flagged an em dash inside a fenced code block, which markdown scoping is supposed to skip. It turns out Vale has no built-in knowledge of the &lt;code&gt;.mdx&lt;/code&gt; extension: without an explicit &lt;code&gt;[formats]&lt;/code&gt; mapping it treats the file as plain text, applies no markdown scoping at all, and lints your code blocks and inline code as if they were prose. My config carried a comment confidently asserting that "Vale's markdown scoping handles fenced code blocks and inline code natively." It does not, because I never told Vale what it was reading. Six of the alerts on my notes tree were phantoms from that.&lt;/p&gt;

&lt;p&gt;So: a rule that could not match, inside a runner that could not fail, parsing a format it did not know how to read. Three independent bugs stacked on one rule, every one of them silent, the whole assembly green. I only found the second and third because I went looking for evidence that my fix to the first one worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Once I saw it, it was everywhere
&lt;/h2&gt;

&lt;p&gt;Then I looked at the rest, and found the same disease in three more checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semgrep.&lt;/strong&gt; My own security baseline mandates it. My own post names it. It is not wired into any workflow, in any repo. A Semgrep that was never installed reports zero findings. So does a Semgrep that is running clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost gate.&lt;/strong&gt; Failing for months in two repos, red on every Terraform PR, for the excellent reason that I never provisioned the GitHub App token it needs to read my private modules. It was not gating anything. It was just red, in the corner, next to the checks that were working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A config file that does not exist.&lt;/strong&gt; My post says known-false-positive suppressions for the IaC scanner go in a &lt;code&gt;.checkov.yaml&lt;/code&gt;. Zero of my nine repos contain that file. I have never written one. I documented a system I had imagined.&lt;/p&gt;

&lt;h2&gt;
  
  
  I audited the tools with tools that lied
&lt;/h2&gt;

&lt;p&gt;It gets worse, because the tools I used to audit the tools had the same disease.&lt;/p&gt;

&lt;p&gt;My first pass used &lt;code&gt;gh run list --limit 100&lt;/code&gt;. It told me my secret scanner had fired twice. It has fired thirty-five times. The window silently truncated at a hundred runs, and my busiest repo has over five thousand. My second pass filtered to pull-request events, which silently discarded every push run. A sampled window told me one mature repo had a spotless record; its real number is 27 failures out of 313. Every one of those queries returned a confident, well-formatted, wrong answer. Not one of them announced what it could not see. I was auditing my instruments with instruments that shared their failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I actually learned
&lt;/h2&gt;

&lt;p&gt;A check that isn't running looks exactly like a check that's passing.&lt;/p&gt;

&lt;p&gt;That is the whole post. Green means "nothing was reported." It does not mean "nothing is wrong," and it does not even mean "something looked." The absence of an alert is perfectly compatible with the absence of a scanner, and from outside the build you cannot tell those two apart.&lt;/p&gt;

&lt;p&gt;It is the same shape as the 10% number, pushed to its limit. There, the surface reading inverted the truth, and only what I happened to remember could set it right. Here there is nothing to remember. A green check withholds the one fact you actually need, whether a check even ran, and no amount of context brings it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it fail on purpose
&lt;/h2&gt;

&lt;p&gt;Make it fail on purpose. That is the entire technique.&lt;/p&gt;

&lt;p&gt;The only reason I know my em dash rule works now is that I fed Vale a line containing an em dash and watched it report the em dash, then removed the fix and watched it report nothing. Ninety seconds, and the before-and-after is the proof. The only reason I know my one grandfathered exemption is scoped to a single file, rather than having quietly switched the rule off everywhere, is that I temporarily broke a different post and watched the gate catch it.&lt;/p&gt;

&lt;p&gt;Every gate you own should have a known-bad fixture: something you can point it at that must fail. If you have never watched a check fail, you do not have a check. You have a decoration that emits green.&lt;/p&gt;

&lt;p&gt;This generalizes well past linters. Any alarm you have never heard ring is in one of two states, and you do not know which one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The punchline
&lt;/h2&gt;

&lt;p&gt;I wrote most of this up as an update to the original post. In it, I said the security stack had caught two real things in four months: a CVE in a transitive dependency, and an IAM policy attached to a user instead of a role. Thin, I said, but the premium is near zero and the downside ends the product.&lt;/p&gt;

&lt;p&gt;The pull request carrying that sentence went red.&lt;/p&gt;

&lt;p&gt;Trivy caught &lt;code&gt;CVE-2026-54466&lt;/code&gt;, rated CRITICAL, in &lt;code&gt;websocket-driver&lt;/code&gt; 0.7.4, reached through &lt;code&gt;@firebase/database&lt;/code&gt;. It had nothing to do with anything I had changed. It was disclosed against code I merged weeks ago, which is precisely the case that a PR-time scan exists to catch. Six minutes to bump it and go green.&lt;/p&gt;

&lt;p&gt;So it is three, not two, and the third arrived while I was typing the sentence that said there were two.&lt;/p&gt;

&lt;p&gt;I would take this trade again tomorrow. Near-zero premium against a downside that ends the product is not a hard call. But "we run X" is a claim, not a fact, until you have watched X fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The preview-environment pattern that ZAP runs against is already written up: &lt;a href="https://lionshead.digital/notes/preview-environments-are-the-dev-loop" rel="noopener noreferrer"&gt;Preview environments are the dev loop&lt;/a&gt;. Vercel plus a fresh Neon Postgres branch per PR, a real running app talking to a real database on every review, for almost nothing. This post was written before that one and sat in a drawer for a week. The next one is on making the invisible checks announce themselves, which is the other half of this problem.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Every pull request gets its own app and its own Postgres branch, for $0</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:43:56 +0000</pubDate>
      <link>https://dev.to/earnestamateur/every-pull-request-gets-its-own-app-and-its-own-postgres-branch-for-0-l92</link>
      <guid>https://dev.to/earnestamateur/every-pull-request-gets-its-own-app-and-its-own-postgres-branch-for-0-l92</guid>
      <description>&lt;p&gt;Every place I've worked plumbed everything to one shared dev environment. One database, one deploy, everyone's half-finished work landing in the same place. The database slowly filled with test accounts named &lt;code&gt;asdf&lt;/code&gt; and orders for negative dollars, until someone declared bankruptcy and refreshed it. Then it filled up again. I've triggered more than one of those refreshes myself.&lt;/p&gt;

&lt;p&gt;Exactly one company I worked at had per-feature environments, and they were narrow: a preview for the GraphQL schema and nothing else. The app, the database, the auth flow, all of it still pointed at shared dev. The one thing they isolated was the one thing that was easy to isolate.&lt;/p&gt;

&lt;p&gt;Lionshead does the opposite. Every pull request gets its own running app AND its own database. Not a mock. Not a shared dev instance with a "please don't touch" Slack message. A real Next.js deploy talking to a real Postgres database that was branched, schema and data, from the parent in under a second and will be destroyed the moment the PR closes.&lt;/p&gt;

&lt;p&gt;The point isn't fancy infrastructure. The point is that local, preview, and production are a stack, not a substitution. Local is where I write the code. Production is where users live. Preview is the rung in between, where a change proves it actually runs before anyone trusts it. Kill that middle step and every review becomes "looks good to me" against a diff, or a push-and-pray to shared dev. The preview environment is where the dev loop closes.&lt;/p&gt;

&lt;p&gt;Here's what happens when I open a PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neon branch, created or refreshed.&lt;/strong&gt; A copy-on-write branch named &lt;code&gt;preview/pr-&amp;lt;number&amp;gt;&lt;/code&gt; is cut from the database's parent branch. Copy-on-write means it's not a dump-and-restore; Neon clones the storage layer by reference and only diverges on write, so the clone itself is effectively instant regardless of how much data is in the parent. The step is idempotent by branch name, so every new commit on the PR refreshes the same branch instead of piling up new ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atlas applies the PR's schema.&lt;/strong&gt; Lionshead manages Postgres schema declaratively with &lt;a href="https://atlasgo.io" rel="noopener noreferrer"&gt;Atlas&lt;/a&gt;. Against the fresh branch, &lt;code&gt;atlas schema apply&lt;/code&gt; reads the desired schema, diffs it against what's live, and applies the difference. The branch now has exactly the schema this PR proposes, running against real Postgres, not a SQLite stand-in or a mock that lies to you about what a foreign key does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel builds and deploys against that branch.&lt;/strong&gt; The build runs with the per-PR &lt;code&gt;DATABASE_URL&lt;/code&gt; injected, so anything that touches the database at build time sees this PR's branch. Then &lt;code&gt;vercel deploy --prebuilt&lt;/code&gt; ships the artifacts with the same URL wired in at runtime. The reviewer gets a link to a live app, and that app is talking to this PR's database and no one else's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A sticky comment carries the link.&lt;/strong&gt; One PR comment, updated in place on every commit, holds the latest preview URL and a short per-commit history. No comment spam, one place to click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On close, the branch is deleted.&lt;/strong&gt; When the PR merges or closes, a cleanup job deletes the Neon branch and confirms it's actually gone. The junkyard never accumulates, because the junkyard is thrown away every time.&lt;/p&gt;

&lt;p&gt;All of this lives in one reusable workflow, &lt;code&gt;lionshead-vercel-preview-deploy.yml&lt;/code&gt;, that every product repo calls. There's a hard-won detail buried in it worth surfacing: it's a single job, not three, because GitHub Actions refuses to pass a step output between jobs when the value looks like a secret. A Postgres connection string contains a password, so a clean "branch job hands the URL to a deploy job" design gets the URL silently scrubbed to an empty string. The fix is unglamorous: keep the URL inside one job. Most of the good infrastructure decisions I make look like that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;Nothing worth mentioning. Neon is on the free plan. Vercel just moved to the $20/month Team plan. That's the whole bill for this capability.&lt;/p&gt;

&lt;p&gt;The reason it's free is the same reason it's fast. Neon bills for storage and compute, not for the number of branches, and an idle branch scales its compute to zero. A per-PR branch that nobody is actively hitting costs effectively nothing while it sits there, and it shares the parent's storage by reference until it's written to. So "a database per pull request" sounds extravagant and turns out to be a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it has actually caught
&lt;/h2&gt;

&lt;p&gt;I'll be honest about this the way I was honest about the security tooling: most bad migrations never reach a PR, because I run migrations against a local Docker Postgres before I push. The preview branch isn't where I catch a typo'd column.&lt;/p&gt;

&lt;p&gt;Where it earns its keep is the full-workflow bugs that are miserable to test locally. Sign-up and sign-in against Firebase, for instance, are genuinely hard to exercise on a laptop; on a real preview URL with a real database, I can watch an account get created and see exactly what we persisted, including the times we captured a wrong or incomplete value. And I can watch all of that happen without dumping a single piece of that test garbage into the shared dev database.&lt;/p&gt;

&lt;p&gt;That's the quieter, bigger win. In every corporate project I've seen, the dev database degrades into a landfill of fake accounts and abandoned test data, until it looks nothing like production and stops being useful for real testing. Per-PR branches give the trash somewhere to live and die. The messy test data stays on the ephemeral branch that gets deleted on merge, and dev stays clean enough to keep nudging it toward looking like prod. Cleaner dev means more focused development, which means bugs that show up in testing are more likely to be real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why almost nobody does this
&lt;/h2&gt;

&lt;p&gt;I've said before that I haven't seen this pattern at solo scale anywhere else, and I still haven't. I've seen it exactly once at corporate scale.&lt;/p&gt;

&lt;p&gt;The reason isn't cost or difficulty. It's that the enabling primitive is rare. Plenty of databases advertise "branching," but they don't mean the same thing. PlanetScale's branching is built for schema changes; its branches don't clone your data. Supabase spins up an empty database and replays your migrations and seed script. Both are useful, neither gives you a full copy of real, production-shaped data per pull request. Neon's copy-on-write branch does, in about a second, for free, and I have not found another managed provider that does the same. Most people simply don't know that per-PR data isolation this cheap is even on the menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas (because there are always gotchas)
&lt;/h2&gt;

&lt;p&gt;Two of these bit me last week, so they're fresh.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 10-branch cap.&lt;/strong&gt; The Neon free plan caps a project at ten branches. I opened several PRs in quick succession, left a few in draft, and every one of them dutifully cut a branch. I hit the ceiling, and preview deploys started failing across the board. The fix was to be stricter about which PRs earn a database: filter the workflow so draft and not-yet-ready PRs don't provision a branch until they're actually up for review. The capability was never the problem. My discipline about triggering it was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Orphaned branches from a skipped cleanup.&lt;/strong&gt; The nastier one. A concurrency-rule interaction was canceling the cleanup job mid-run when a late push raced a merge, so the branch-delete never executed and branches leaked, which of course marched me straight back into the 10-branch cap. The fix was giving the cleanup job its own concurrency group that never cancels in progress, plus making the delete step actually authenticate instead of failing silently while reporting green. A teardown, once it starts, has to finish. That one cost me an afternoon and a good deal of "why do I have eleven branches when I have three open PRs."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's slow, though not the part you'd guess.&lt;/strong&gt; Full transparency: the preview environment is by far the longest thing on the PR. The deploy job runs about five minutes, and the whole preview pipeline lands closer to ten once you count waiting on sibling checks. I pulled the step timings across recent runs to see where those five minutes actually go, and almost none of it is the database. Creating or refreshing the Neon branch takes 10 to 15 seconds on a cold create and effectively nothing when it's just refreshing an existing one; the copy-on-write clone is instant, and the seconds that do pass are spent standing up the branch's compute endpoint, not copying data. Applying the schema with Atlas adds another 10 to 15 seconds, or nothing when there's no diff. The remaining four-plus minutes are all Vercel: about 20 seconds to install the CLI, then three and a half to four minutes for the Next.js production build, then about 25 seconds to upload the artifacts. So the database work tops out around half a minute and is usually far less. Building and shipping a whole app per PR is the slow part, and it would be slow with or without the branch. Everything else on the PR finishes while the build is still going. I think that's an acceptable price for a real running app on every change, but I won't pretend it's instant.&lt;/p&gt;

&lt;p&gt;None of these are reasons not to do it. They're the tax you pay once, learn from, and encode into the workflow so the next repo inherits the fix instead of rediscovering the bug. Which, if you've read the last post, you'll know is a lesson I keep having to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The preview deploy needs secrets, a Vercel token, a Neon key, to do any of this. For a long time that meant long-lived tokens sitting in repository secrets, which is exactly the kind of thing the security post frets about. The next note in this sub-series is how that got fixed: OIDC through Doppler and GitHub Actions, and how short-lived, federated credentials killed the long-lived token entirely.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>github</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The security checks in every Lionshead PR</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:43:55 +0000</pubDate>
      <link>https://dev.to/earnestamateur/the-security-checks-in-every-lionshead-pr-1k49</link>
      <guid>https://dev.to/earnestamateur/the-security-checks-in-every-lionshead-pr-1k49</guid>
      <description>&lt;p&gt;At enterprise scale, a breach is a bad quarter. You have a legal team to coordinate disclosure. A disaster-recovery plan you drill annually. A PR team to control the narrative. A security team to quarantine, investigate, and triage the incident. A bank account big enough to absorb regulatory fines, class-action settlements, and the customer churn that follows.&lt;/p&gt;

&lt;p&gt;At solo scale, you have none of those. A real breach of user data will almost certainly end the product. Not "hurts the brand," not "sets us back a quarter." Ends it.&lt;/p&gt;

&lt;p&gt;So you breach yourself first.&lt;/p&gt;

&lt;p&gt;The security stack in every Lionshead PR is an automated self-breach. Every merge attempt is scanned by tools a mid-sized security team would run against production code weekly. If any of them find something, the merge blocks. The vulnerability never reaches production, and the version of me that would have caused an incident never gets the chance.&lt;/p&gt;

&lt;p&gt;Here's what actually runs on every PR against every Lionshead product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/aquasecurity/trivy" rel="noopener noreferrer"&gt;Trivy filesystem&lt;/a&gt;&lt;/strong&gt; — vulnerability, secret, and license scanning across the whole repo. Runs on every PR, no path gate. Catches dependency CVEs, accidental hardcoded credentials, license conflicts. Configured via &lt;code&gt;trivy.yaml&lt;/code&gt; at the repo root; fails on any CRITICAL or HIGH severity. Trivy is Aqua Security's open-source scanner and it's the single best free vulnerability scanner I've used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/aquasecurity/trivy" rel="noopener noreferrer"&gt;Trivy IaC (Terraform)&lt;/a&gt;&lt;/strong&gt; — the same tool, second mode, against Terraform files. Catches insecure infrastructure defaults (public buckets, permissive IAM roles, unencrypted resources). Path-gated: only runs when &lt;code&gt;terraform/**&lt;/code&gt; files change. Same severity gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;Gitleaks&lt;/a&gt;&lt;/strong&gt; — secret scanning across the entire git history, not just the current diff. Catches API keys, tokens, private keys, connection strings that someone (probably me) committed years ago and later deleted. The secret is still in the history and still exploitable. Runs on every PR. Uses the default ruleset unless the repo drops a &lt;code&gt;.gitleaks.toml&lt;/code&gt; override.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/bridgecrewio/checkov" rel="noopener noreferrer"&gt;Checkov&lt;/a&gt;&lt;/strong&gt; — a second IaC security scanner, run alongside Trivy IaC for defense in depth. Checkov and Trivy IaC use different rulesets and different heuristics; they catch different classes of problem. Path-gated on &lt;code&gt;terraform/**&lt;/code&gt;. Skip lists for known-false-positive rules go in &lt;code&gt;.checkov.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/zaproxy/zaproxy" rel="noopener noreferrer"&gt;OWASP ZAP baseline&lt;/a&gt;&lt;/strong&gt; — active DAST against the PR's Vercel preview URL. When a repo produces a preview deploy, ZAP hits the deployed site with a baseline security scan. Catches missing security headers, cookie configuration issues, common injection vectors. Fails on any WARN+ alert; per-rule suppressions live in the repo's &lt;code&gt;.zap/rules.tsv&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/rhysd/actionlint" rel="noopener noreferrer"&gt;Actionlint&lt;/a&gt;&lt;/strong&gt; — GitHub Actions workflow linting. Not strictly security, but security-adjacent: catches syntax errors, invalid contexts, and permission-scope typos that would silently over-scope your workflow's access. Path-gated on &lt;code&gt;.github/workflows/**&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/koalaman/shellcheck" rel="noopener noreferrer"&gt;Shellcheck&lt;/a&gt;&lt;/strong&gt; — shell script static analysis. Catches quoting errors, command-injection patterns, and the classic "unquoted variable in an &lt;code&gt;rm&lt;/code&gt; command" bug that has ended more careers than any single vulnerability. Path-gated on &lt;code&gt;*.sh&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/hadolint/hadolint" rel="noopener noreferrer"&gt;Hadolint&lt;/a&gt;&lt;/strong&gt; — Dockerfile linting. Catches insecure image bases, missing &lt;code&gt;USER&lt;/code&gt; directives, and other Dockerfile-shape issues that expand blast radius on container escape. Path-gated on &lt;code&gt;Dockerfile*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action pins check&lt;/strong&gt; — a Lionshead-authored guardrail that validates every third-party GitHub Action referenced in workflow files is pinned to a full commit SHA, not a moving tag. Prevents a supply-chain attack against an action's tag from silently propagating into Lionshead's CI. Fails PRs that add or update actions without a SHA pin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.infracost.io" rel="noopener noreferrer"&gt;Infracost&lt;/a&gt; + OPA cost gate&lt;/strong&gt; — not security in the CVE sense, but security in the "my card gets charged $10K/month by mistake" sense. Runs on every Terraform PR, computes the cost delta, and pauses the PR on a GitHub Environment approval gate when the delta exceeds $50/month. I covered this in more detail in &lt;a href="https://lionshead.digital/notes/why-i-run-enterprise-grade-ci-at-solo-founder-scale" rel="noopener noreferrer"&gt;the last post&lt;/a&gt;; mentioned here because it lives in the same workflow.&lt;/p&gt;

&lt;p&gt;Two more layers run outside the PR gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduled security audit&lt;/strong&gt; — every product repo runs &lt;code&gt;lionshead-security-audit.yml&lt;/code&gt; daily against the default branch. It runs &lt;code&gt;npm audit&lt;/code&gt; (or the language-appropriate equivalent) plus a full Trivy filesystem scan. Findings open or update a single &lt;code&gt;security:dependency&lt;/code&gt; tracking issue per repo; clean runs close any open issue. The point is to catch CVEs disclosed &lt;em&gt;after&lt;/em&gt; code was merged, not just what a PR introduces. The majority of real-world CVEs are disclosed against code already in production; a PR-time scan alone misses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/renovatebot/renovate" rel="noopener noreferrer"&gt;Renovate&lt;/a&gt;&lt;/strong&gt; — dependency-update automation. The ci-standards Renovate preset applies to every product repo via a one-line &lt;code&gt;renovate.json&lt;/code&gt;. Renovate opens PRs to bump dependencies, pin action SHAs, and update base container images. Its &lt;code&gt;github-actions&lt;/code&gt; manager keeps the pinned reusable workflow SHAs current, which is how new products cloned from &lt;code&gt;template-product&lt;/code&gt; stay on the latest ci-standards workflow without manual intervention. Weekly schedule keeps the noise bounded.&lt;/p&gt;

&lt;p&gt;Three things are on the roadmap but not shipping yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/semgrep/semgrep" rel="noopener noreferrer"&gt;Semgrep&lt;/a&gt; (SAST)&lt;/strong&gt; — the security baseline mandates Semgrep OSS for static-analysis coverage as a free-and-open-source substitute for GitHub Advanced Security. Rollout across all products is in progress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OWASP ZAP as a scheduled cron against dev&lt;/strong&gt; — today, ZAP runs at PR time against the preview URL. The plan is to also run ZAP against the shared dev environment weekly to catch drift between preview builds and the environment that lives longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-commit hooks&lt;/strong&gt; — a Husky-driven local hook stack that runs the most annoying failure modes (a Gitleaks pre-commit scan, at minimum) before code even gets to the PR. Removes a full round-trip when you catch a mistake before pushing. On the shortlist to ship next.&lt;/p&gt;

&lt;p&gt;The whole stack costs zero in licensing. Every tool above is open source. The cost is time to wire them up and ongoing time to tune false-positive suppressions. That cost is measured in hours, once, not months. What it buys is the difference between "my product survives" and "my product ends" in the scenario where I fat-finger a Terraform PR at 11pm.&lt;/p&gt;

&lt;p&gt;That is the trade at solo scale. It happens to look like the trade at enterprise scale for exactly the same reason.&lt;/p&gt;







&lt;h2&gt;
  
  
  Update, 16 July 2026: what it cost, and what it caught
&lt;/h2&gt;

&lt;p&gt;Someone on Hacker News asked how often these checks actually hold up my PRs. I didn't know, which bothered me, so I went and counted every PR check this stack has ever run: 2,864 of them across nine repos. 286 failed, call it 10%.&lt;/p&gt;

&lt;p&gt;The failures aren't spread out, and that's the part I didn't expect. They arrive in tight clusters of three or four days, and every cluster sits on top of a setup event. The biggest was late April, when I pulled these checks out of Vesper and into the shared ci-standards workflow. Eighteen Infracost failures over four days while I shook out credential and fetch-depth bugs, then ten weeks of silence. Another cluster in May when Crisper adopted the same workflow. Another in July when I bootstrapped some skeleton repos that are parked until Vesper ships. Between the clusters it's quiet for months. The repos with the worst failure rates are the ones that have only ever been in setup and have no steady state to average against.&lt;/p&gt;

&lt;p&gt;So the cost isn't a tax on every PR. It's a bill that comes due when I migrate something, and it's mostly me misconfiguring my own plumbing rather than the scanners crying wolf.&lt;/p&gt;

&lt;p&gt;The other surprise: tests, not security, are what actually block me. Failing tests account for roughly half of every PR check failure I've ever had. All the security tooling combined is well under a quarter.&lt;/p&gt;

&lt;p&gt;What the security tools have caught, honestly: not much. Trivy found a real CVE in a transitive &lt;code&gt;form-data&lt;/code&gt; dependency and I bumped it. Checkov found a real IAM policy attached to a user instead of a role. It also flagged &lt;code&gt;AKIAEXAMPLEEXAMPLE00&lt;/code&gt; in a &lt;code&gt;.env.example&lt;/code&gt;, and read a Terraform &lt;code&gt;format("postgresql://%s:%s@...")&lt;/code&gt; string as hardcoded credentials when the password was a &lt;code&gt;random_password&lt;/code&gt; reference. Which is to say it caught the correct implementation and would have sailed straight past a bad one.&lt;/p&gt;

&lt;p&gt;And then, while I was writing this very update, the pull request carrying it went red. Trivy flagged &lt;code&gt;CVE-2026-54466&lt;/code&gt;, rated CRITICAL, in &lt;code&gt;websocket-driver&lt;/code&gt; 0.7.4, reached through &lt;code&gt;@firebase/database&lt;/code&gt;. It had nothing to do with anything I'd changed. It was disclosed against code I merged weeks ago, and the PR-time scan is simply where I happened to meet it. Bumped to 0.7.5, green again, about six minutes of work.&lt;/p&gt;

&lt;p&gt;So: &lt;del&gt;two real findings in four months&lt;/del&gt; &lt;strong&gt;three real findings in four months, one of them CRITICAL, and the third arrived while I was typing the sentence that said there were only two.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's still a thin haul if you measure it the way a security team measures things when it needs headcount approved. But that was never the trade. The premium is a few hours per migration and zero dollars, and what it covers is the scenario where I fat-finger a Terraform PR at 11pm and end the product. At that price I don't need it to catch much. I need it to catch the one. It just caught one while I was mid-sentence arguing it rarely does.&lt;/p&gt;

&lt;p&gt;A correction and a clarification to the post above, while I'm here.&lt;/p&gt;

&lt;p&gt;The post says the tuning cost is "measured in hours, once, not months." Hours is right. Once is wrong. It's hours per migration, and every new repo keeps rediscovering the same false positives because I've written a slightly different allowlist in each one instead of fixing &lt;code&gt;template-product&lt;/code&gt; so the next repo inherits it. That's on me, not the tooling. For the full effect: publishing this update tripped Gitleaks on the &lt;code&gt;AKIAEXAMPLEEXAMPLE00&lt;/code&gt; two paragraphs up, and I fixed it by writing yet another per-repo allowlist. Which tells you exactly how much I've learned.&lt;/p&gt;

&lt;p&gt;On ZAP: what runs at PR time today is the baseline passive scan. Full active DAST is on the roadmap, not shipping yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Next in the CI/CD sub-series: the preview environment pattern that ZAP runs against. Vercel plus a fresh Neon Postgres branch per PR, how the reviewer gets a real running app talking to a real database on every PR, and why that costs almost nothing.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>I'm one person, and my CI does what a mid-size eng org's does. Here's why.</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:36:36 +0000</pubDate>
      <link>https://dev.to/earnestamateur/im-one-person-and-my-ci-does-what-a-mid-size-eng-orgs-does-heres-why-578j</link>
      <guid>https://dev.to/earnestamateur/im-one-person-and-my-ci-does-what-a-mid-size-eng-orgs-does-heres-why-578j</guid>
      <description>&lt;p&gt;Corporate CI is often the red-headed stepchild of the dev team. Written six years ago by a consulting firm, held together with cron jobs whose owners left in two reorgs, touched only when it breaks. Nobody on the current team could explain what half the steps do.&lt;/p&gt;

&lt;p&gt;Lionshead's CI runs a security suite, an Infracost cost gate, IaC scanning, secret scanning, schema-migration validation, and a preview-environment deploy before any PR can merge. Categorically the same coverage as many mid-sized engineering orgs.&lt;/p&gt;

&lt;p&gt;I am one person.&lt;/p&gt;

&lt;p&gt;The obvious question, from other indie founders, is why. The reusable workflows in &lt;code&gt;ci-standards&lt;/code&gt; took months to write. Nobody is going to breach my pre-launch SaaS in month three. Why not push to main and let Vercel deploy?&lt;/p&gt;

&lt;p&gt;Because this is the way cloud software is built. I don't know how to build it any other way, and I don't want to relearn. There's no three-person dev team here, no DevOps engineer, no IT department. Nothing about being alone changes what "shipping safely" means. It only changes who has to remember it, and if the answer is me, then the answer needs to be automation.&lt;/p&gt;

&lt;p&gt;The shops I have seen succeed treat the CI/CD pipeline as first-class code. The developers who write the product also write the pipeline. The knowledge of how one flows into the other compounds over the years. The shops I have seen fail leave CI to consultants, or hire it out to a platform team that has never shipped their product, or worst of all, both. In my experience, no exception to that pattern has stuck. The shape of the CI pipeline is a leading indicator of how the whole engineering culture works.&lt;/p&gt;

&lt;p&gt;Lionshead's pipeline is four areas: &lt;strong&gt;build&lt;/strong&gt;, &lt;strong&gt;test&lt;/strong&gt;, &lt;strong&gt;security&lt;/strong&gt;, &lt;strong&gt;deploy&lt;/strong&gt;. Two of those areas make choices unusual at solo scale.&lt;/p&gt;

&lt;p&gt;Security. Trivy for vulnerabilities and IaC, Gitleaks for secrets, Checkov for policy, plus linters that keep the workflow files themselves honest. All open-source, no licensing cost. Any one of these is a one-line YAML addition and takes ten minutes to wire up. The unusual choice isn't setup. It's committing to run all of them, keep them maintained across every product repo, and enforce them as merge gates when the only person they block is me.&lt;/p&gt;

&lt;p&gt;Preview environments. Every PR spins up its own Vercel deploy AND its own fresh Neon Postgres branch: a copy-on-write clone of prod's schema. The reviewer clicks the preview URL and gets a real running app talking to a real database. Schema migrations run against actual Postgres before merge, not against a mock or a shared dev database. When the PR closes, the branch is destroyed. This costs nearly nothing because Neon bills for storage and compute, not for branches. I have not seen this pattern at solo scale anywhere else.&lt;/p&gt;

&lt;p&gt;The check that deserves specific mention beyond those two is Infracost. It runs on every Terraform PR and requires human approval when a change would add $50 or more to my monthly cloud spend. Fifty dollars is not the number that would ruin me. Fifty dollars is the number where I would want to look twice at the diff. Between an agent auto-completing a Terraform block wrong, and me clicking through a review too fast, I would rather burn a merge-queue slot than wake up to a $10,000-a-month resource I did not mean to create. Every solo founder running on their own cash should have this exact gate. Almost none do.&lt;/p&gt;

&lt;p&gt;All of it runs end-to-end in about five minutes. That's not despite the number of checks. It's a function of designing the pipeline with parallel jobs and path-gated conditionals from day one, not retrofitting them on after the fact.&lt;/p&gt;

&lt;p&gt;The line between too much CI and not enough CI is a straightforward one. It lands wherever developers no longer need to remember how to get code into a live environment, or troubleshoot when it doesn't. Below that line, you pay a tax on every ship in ceremony that used to happen automatically. Above that line, you pay a tax on every setup in ceremony that never had to happen. My CI investment sits right at the line for one person. It happens to look like the CI at a mid-sized org because the line is drawn by the same physics, not by the size of the team.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;This post is the "why." The rest of the CI/CD sub-series is the how. Coming up: the specific security checks that ship with every Lionshead PR, how the Vercel + Neon-branch preview environment is provisioned, the OIDC secret flow through Doppler, and the path-gated test routing that keeps a five-product portfolio building against one shared workflow.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Every website should have a colophon</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 10:36:34 +0000</pubDate>
      <link>https://dev.to/earnestamateur/every-website-should-have-a-colophon-3aah</link>
      <guid>https://dev.to/earnestamateur/every-website-should-have-a-colophon-3aah</guid>
      <description>&lt;p&gt;Every developer I know has the same itch. Pop the hood on a website, a SaaS app, an app on the App Store, and see how it was built. Which framework. Which hosting. Which type. The craft underneath the surface.&lt;/p&gt;

&lt;p&gt;The colophon page is the formal scratch to that itch.&lt;/p&gt;

&lt;p&gt;The word comes from publishing. A colophon is the small inscription on the last page of a book that tells you the typeface, the paper, the press, the binding. The book's &lt;em&gt;how&lt;/em&gt;. Stripe Press still does this. Information Architects does this. A small corner of the web that takes its lineage from print has been doing it for years.&lt;/p&gt;

&lt;p&gt;I had never heard the word until six months ago.&lt;/p&gt;

&lt;p&gt;That isn't a humblebrag. Ten-plus years in corporate engineering, up through senior director, and the word "colophon" never came up. The companies I worked at shipped real software to millions of users, and not one of them had a public page that said "here is how we built this." It wasn't anyone's job. It wasn't a category.&lt;/p&gt;

&lt;p&gt;I came across it earlier this year, looked it up, read a few examples, and built one that weekend.&lt;/p&gt;

&lt;p&gt;Lionshead's colophon is at &lt;a href="https://lionshead.digital/colophon" rel="noopener noreferrer"&gt;lionshead.digital/colophon&lt;/a&gt;. Stack, type, color source, build method, hosting. One screen, no decoration. The same structure now ships in every Lionshead product per a company rule. They all have colophons.&lt;/p&gt;

&lt;p&gt;I added it because building things can be a craft, not just a job. Corporate engineering taught me to ship. Lionshead is teaching me to sign my work.&lt;/p&gt;

</description>
      <category>design</category>
      <category>developer</category>
      <category>webdev</category>
      <category>website</category>
    </item>
    <item>
      <title>Keeping 10 repos in sync without a monorepo or a platform team</title>
      <dc:creator>Allen Brooks</dc:creator>
      <pubDate>Thu, 30 Jul 2026 04:39:20 +0000</pubDate>
      <link>https://dev.to/earnestamateur/keeping-10-repos-in-sync-without-a-monorepo-or-a-platform-team-2m9h</link>
      <guid>https://dev.to/earnestamateur/keeping-10-repos-in-sync-without-a-monorepo-or-a-platform-team-2m9h</guid>
      <description>&lt;p&gt;Most dev shops don't have standards. At least not codified and documented standards. They have a Confluence space with someone's 2021 attempt at a style guide buried under three years of half-finished runbooks and obsolete service docs. The rules aren't enforced because nobody reads them. The shops that do enforce standards pay for it: a platform team, a custom lint server, a quarterly review cadence, and someone whose job is to argue with developers about line length.&lt;/p&gt;

&lt;p&gt;Lionshead has standards, and they're enforced for free. They live in code, in &lt;code&gt;.claude/rules/&lt;/code&gt; and a BMAD module, and the AI agents doing the keystrokes read them on every story. There's no human in the middle to forget the rule; the agent reads it and writes the code in the same breath. That's not the same trade as "humans who follow the rules better." The AI is doing the writing too; the free enforcement is a side effect of swapping the actor, not a process improvement layered over a human team. That changes the problem. The question stops being "how do we get developers to follow the rules" and starts being "how do we get the same rules into ten unrelated product repos without a monorepo or a platform team."&lt;/p&gt;

&lt;p&gt;This post is the answer. A small CLI I call the reconciler: pull-based, hash-compared, idempotent. It ships a manifest from a central repo and lets each consumer adopt the parts it wants. Here is what it does, what it costs to build, and where it ends.&lt;/p&gt;

&lt;p&gt;I have operated at the ten-repo scale before, and the reconciler is built for that target. Lionshead today is one product (Vesper); this is the system for where I am going, not where I am.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;The familiar approaches to keeping many repos in sync all trade the same three axes: friction at edit time, friction at consumer time, and how much shared infrastructure the consumers have to swallow. Pick any two, lose the third.&lt;/p&gt;

&lt;p&gt;Copy-paste. Zero infrastructure. The cost shows up at every fix: ten repos, ten patches, and the moment one repo skips a patch the divergence starts. Worse, copy-paste makes the canonical version invisible. No one can grep it without first deciding which repo's copy to trust. I shipped two months on copy-paste before the cost compounded; it compounds fast.&lt;/p&gt;

&lt;p&gt;Git submodules. One source of truth, a real link between consumer and producer, and a developer experience that is genuinely awful. Submodules break IDE checkouts, break clone scripts, and force every consumer to handle a sub-checkout step that someone new will get wrong on day one. I tried submodules for two days; the cost showed up faster than copy-paste.&lt;/p&gt;

&lt;p&gt;Monorepo. Solves drift completely. Also forces every product onto a shared CI, a shared release cadence, and a shared dependency graph. That cost is paid even by products that have nothing to do with each other. The big-company default; the wrong default for the operator who runs ten small unrelated things.&lt;/p&gt;

&lt;p&gt;All three trade the same axes in different proportions. The reconciler trades a fourth axis: it accepts a small per-consumer install cost in exchange for keeping every consumer's repo shape, CI, release cadence, and dependency graph independent. That is the only trade I was willing to make at the ten-repo scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the reconciler actually does
&lt;/h2&gt;

&lt;p&gt;The CLI is published to GitHub Packages under the &lt;code&gt;@lionsheaddigital&lt;/code&gt; scope. Consumers configure their npm token once per machine. After that, &lt;code&gt;npx @lionsheaddigital/setup&lt;/code&gt; just works.&lt;/p&gt;

&lt;p&gt;The CLI reads a single declarative manifest, &lt;code&gt;lionshead.json&lt;/code&gt;, from the central &lt;code&gt;ci-standards&lt;/code&gt; repo over plain HTTPS to &lt;code&gt;raw.githubusercontent.com&lt;/code&gt;. The manifest lists every standards file the central repo owns: BMAD module sources, the Claude Code rule files, GitHub Actions reusable workflows, the GitHub project sync script, and a few others. Each entry carries a &lt;code&gt;sha256&lt;/code&gt; hash of the upstream file.&lt;/p&gt;

&lt;p&gt;For every file declared in the manifest, the CLI walks one branch:&lt;/p&gt;

&lt;p&gt;The heart of the loop is a hash comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/setup/lib/reconcile.mjs (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remoteStandards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localInstalled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remoteFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;remoteStandards&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localInstalled&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toDownload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rf&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;remoteFiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;rf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;toDownload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a consumer's local copy of a declared file already matches the upstream hash, the CLI does nothing for that file. If the hash differs (the consumer is missing the file, or upstream has moved on, or someone hand-edited the local copy), the CLI downloads the upstream version and overwrites the local one. No prompt. No merge. No three-way confusion. Upstream wins; local edits to managed files are not allowed.&lt;/p&gt;

&lt;p&gt;The full run on a clean consumer takes about three seconds. The full run on a clean, up-to-date consumer takes about one second and prints "Standards up to date." That second case is the one that earned the reconciler its name: it is safe to run a hundred times in a row, and the only side effect is that the operator knows the consumer is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard parts
&lt;/h2&gt;

&lt;p&gt;The interesting design decisions in this kind of CLI are not the visible ones (the manifest shape, the file copy). They are the boring ones underneath.&lt;/p&gt;

&lt;p&gt;Idempotency is the first hard part. The hash-compare and the conditional download together give you "do nothing when nothing has drifted." That looks easy until you find the case where BMAD's own installer silently overwrites a managed file with stale bundled content after the main download loop has already updated it. The reconciler now does a second verify-and-restore pass after BMAD runs; without it, a clean run would show "Standards up to date" while the on-disk SHA had quietly diverged from the manifest. That pass cost a day to discover and an afternoon to write. It is the kind of bug that only shows up at the seam between two tools that both think they own the same files.&lt;/p&gt;

&lt;p&gt;Partial adoption is the second hard part. Not every consumer wants every standard. A static marketing site does not need the Test Architect workflow; a backend service does not need the design-tokens rule. The manifest supports per-consumer opt-outs via a small &lt;code&gt;lionshead.json&lt;/code&gt; in the consumer repo that lists explicit skip paths. The escape hatch is necessary; without it the reconciler is a coupling mechanism dressed up as a distribution one.&lt;/p&gt;

&lt;p&gt;Upgrade safety is the third hard part. The reconciler is itself one of the things it ships. Bootstrapping a brand-new consumer is a chicken-and-egg moment: the consumer does not yet have the CLI installed, but the CLI is what installs the CLI. The answer is &lt;code&gt;npx&lt;/code&gt;: the CLI runs without a prior install, fetches itself from GitHub Packages, runs once, and exits. The local install only matters for subsequent runs, and those can be &lt;code&gt;npx&lt;/code&gt; too. The whole bootstrap is one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to do this
&lt;/h2&gt;

&lt;p&gt;The reconciler is right anywhere above three repos. Below three, copy-paste is genuinely cheaper, and the per-file maintenance cost is small enough that the operator can absorb drift mentally. The architecture has no real upper bound on consumer count: the CLI is stateless, every consumer is independent, and adding one is one &lt;code&gt;npx&lt;/code&gt; away.&lt;/p&gt;

&lt;p&gt;The real ceiling is governance, not scale. Review doesn't disappear in this model; it shifts upstream to the central standards repo, where PRs are the negotiation surface. That works fine when there's an agreed owner of the central repo. It breaks down when there isn't: the reconciler's "upstream wins" semantics turn a governance question into a technical one consumers can't appeal. A monorepo handles disagreement by making every change visible to all consumers in the same commit; the reconciler doesn't.&lt;/p&gt;

&lt;p&gt;And the reconciler is wrong when drift is a feature, not a bug. Research codebases, demo branches, throwaway prototypes: these benefit from divergence. A pull-based "upstream wins" CLI run against a demo branch will quietly undo the demo. The right fix there is to mark the consumer out of scope, not to weaken the reconciler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outro: the Building Lionshead thread
&lt;/h2&gt;

&lt;p&gt;The reconciler is one chapter in the Building Lionshead series. Post one was a sketch of why I built a company-specific BMAD module; the post you are reading is what the distribution mechanism for that module looks like in code. The series's spine is "small-company process at small-company cost"; this post is one chapter of it.&lt;/p&gt;

&lt;p&gt;The next post in the series will be about the BMAD module itself: what is in it, what the boundary is between Lionshead-shaped behavior and stock BMAD behavior, and why I keep that boundary thin. After that, a post on the rule files in &lt;code&gt;.claude/rules/&lt;/code&gt; and the way they get loaded into agent context. Both posts depend on the reconciler existing; this post is the prerequisite.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
