<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Failing Closed]]></title><description><![CDATA[Musings, technical dives, and incoherent ramblings of a mad-man, focused on agentic engineering, scalable architecture, and quality observable code.]]></description><link>https://www.sperg.dev</link><image><url>https://substackcdn.com/image/fetch/$s_!xe_p!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0106609-0e83-41c4-9c0c-a9e5ea473f4f_500x500.png</url><title>Failing Closed</title><link>https://www.sperg.dev</link></image><generator>Substack</generator><lastBuildDate>Tue, 15 Sep 2026 02:51:56 GMT</lastBuildDate><atom:link href="https://www.sperg.dev/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Pat]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[packet64@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[packet64@substack.com]]></itunes:email><itunes:name><![CDATA[Pat]]></itunes:name></itunes:owner><itunes:author><![CDATA[Pat]]></itunes:author><googleplay:owner><![CDATA[packet64@substack.com]]></googleplay:owner><googleplay:email><![CDATA[packet64@substack.com]]></googleplay:email><googleplay:author><![CDATA[Pat]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[What’s it worth to you?]]></title><description><![CDATA[Every adoption decision is the same question. The answer is somebody else&#8217;s, and it doesn&#8217;t transfer.]]></description><link>https://www.sperg.dev/p/whats-it-worth-to-you</link><guid isPermaLink="false">https://www.sperg.dev/p/whats-it-worth-to-you</guid><dc:creator><![CDATA[Pat]]></dc:creator><pubDate>Sun, 19 Jul 2026 23:23:12 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!t1Yh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I spent three weeks debugging a Redpanda cluster that kept having sadness. The root cause was a choice I&#8217;d made in about ten minutes a few weeks prior, and known was wrong the whole time.</p><p>The pipeline was big, but about as simple as they come: data in, transform, data out, at around 450,000 messages a second sustained, bursting past three million. The only real requirement was that every incoming message get its source stamped into it on the way through. A trivial mutation. Three bytes appended to the end of the protobuf, which the wire format is perfectly happy to let you do, since a decoder reading tag-value pairs just takes the last value it sees for a given field. So I did the thing that looked easy. I wrote a WASM transform, loaded it into Redpanda, let it blindly append the three bytes, and moved on.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.sperg.dev/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Failing Closed is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>The transform was correct. It worked great! That was never the problem. The problem was that Redpanda Transforms are hot on paper, and in my case were the thing quietly destabilizing the whole cluster at the scale I was running at, and the logs - they told me nothing at the time. Three weeks of chasing instability that had no signal attached to it. When I finally pulled the transform and wrote the boring Go consumer I should have written on day one, the instability magically disappeared.</p><p>Here&#8217;s the part that stings (my ego). My gut said &#8220;just write the consumer&#8221; at the very start. I ignored it because the transform was less code and lived inside the broker, so there was no separate service to run and deploy. Both of those were true. Also, it&#8217;s WASM, transpiled from Go - fast as hell! Also true. But .. none of them were worth three weeks.</p><p>I&#8217;ve spent most of my career on the deciding end of calls like that one. Toolchains, workflows, libraries, occasionally the whole stack. It usually starts the same way - someone gets excited and drops a link in a channel, a new build tool or ORM or queue, whatever it is this week. Sound familiar? None of that is wrong. It probably is faster, the DX probably is better. But.. the part nobody leads with is what it costs, and who ends up paying. Which is why I keep landing back on my same soap box:</p><blockquote><p><em>What meaningful <strong>value</strong> does adopting it provide, and at what <strong>cost</strong>?</em></p></blockquote><p>That's it. That's my whole method. It sounds like the kind of thing you'd find in a slide deck, and the reason it isn't useless is that both halves are much harder to answer honestly than they look. I answered both wrong on a pipeline I was building alone, with my own gut handing me the right answer for free.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!t1Yh!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!t1Yh!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!t1Yh!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png" width="1024" height="1024" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:1024,&quot;width&quot;:1024,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:null,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:null,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:null,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!t1Yh!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 424w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 848w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!t1Yh!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F2e90019d-6c1a-4203-8c53-0d0d914d3a47_1024x1024.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Cost, Value, and Developer Experience - all intricately entwined</figcaption></figure></div><h2>Value is what you can point at later</h2><p>Value is the easy half to talk about and the hard half to prove (see: <em>quantify</em>), because most of what gets called &#8220;value&#8221; in the moment is essentially <em>vibes</em>. It&#8217;s less code. It&#8217;s cleaner. It lives in the broker instead of in yet another service I have to babysit.</p><p>Some of that&#8217;s real, and the WASM transform had genuine value (on the surface) - fewer moving parts, nothing extra to deploy, the mutation sitting right where the data already was. I&#8217;m not going to pretend those were imaginary - they&#8217;re exactly the kind of alluring feature that wins an argument in your own head at ten in the morning.</p><p>The test for whether you&#8217;ve found real value is whether you could show it to someone in six months. Not describe it. <strong>Show it</strong>. And the value that actually held up in that whole mess wasn&#8217;t on the transform&#8217;s side at all. It was on the boring consumer&#8217;s - a stack trace when something broke, standard tooling, logs that said words. That&#8217;s developer experience as a line item instead of a vibe. The transform felt better to write. The consumer was better to live with, and I could prove it, because one of them left me blind and with a headache for three weeks, while the other didn&#8217;t (even though it took longer to spin up).</p><h2>Cost is almost never money</h2><p>Ask a developer what something costs and they won&#8217;t say dollars. They&#8217;ll say time, complexity, risk, and how much worse their week gets. All of it converts to money eventually, yes, but slowly enough that nobody has the conversion in the moment.</p><p>Time is the one that gets underbid, and <em>it&#8217;s not implementation time</em>. Implementation you can estimate. What you can&#8217;t estimate is time-to-understand - the stretch where you&#8217;re slower because you&#8217;re learning a new way to solve a problem you already knew how to solve. The usual story is that the person proposing the change has already paid that cost, so <strong>their estimate is honest and also wrong</strong>, priced from the far side of the curve.</p><p>What my three weeks taught me is that it&#8217;s worse than that. I was the person proposing the change and the person paying for it. There was no over-eager colleague to blame. I&#8217;d done the reading, I understood transforms, and I still priced the decision from the wrong side, because &#8220;I know how this works&#8221; and &#8220;I know what this costs to run&#8221; are not the same knowledge and I treated them like they were.</p><p>Then there&#8217;s risk, which is where the unknown unknowns live, and which you cannot estimate by definition. Cluster instability with no diagnostic signal is what an unknown unknown looks like when it finally shows up - not a bug you can point at, just a system that&#8217;s unhappy and won&#8217;t tell you why. You don&#8217;t get to estimate that in advance. You only get to decide how much of it you&#8217;re willing to eat, and I ate a lot of it without meaning to.</p><p>And assessing all of this has its own cost. Sometimes the evaluation is more expensive than the thing is worth, and the right move is to not evaluate it and go with the boring option you already know and trust. That feels like laziness, which is why people don&#8217;t say it out loud. It would have been the correct call here.</p><h2>The guideline is somebody else&#8217;s context</h2><p>Community guidelines are guidelines. Best practices are best-for-somebody, and that somebody wrote them up because it worked for them, which is the best possible reason and also not a reason for you.</p><p>WASM data transforms are the modern, promoted way to mutate in-stream. That&#8217;s not marketing, it&#8217;s genuinely a nice pattern, and for plenty of teams it&#8217;s the right one. The docs point you at it. The blog posts point you at it. Everything points you at it, and none of that was evidence about my situation.</p><p>My situation was one engineer, one trivial mutation, 450k messages a second sustained, and a cluster whose stability was the entire ballgame. Drop compute into the broker at that throughput and the broker&#8217;s stability is now downstream of my compute, which is a trade a bigger team with more eyes and better broker-level observability might make on purpose. I made it by accident, because the pattern was the recommended one and I didn&#8217;t stop to ask which context produced the recommendation. <strong>If you can&#8217;t articulate the situation a guideline came out of, you can&#8217;t tell whether you&#8217;re standing in it.</strong></p><h2>The scale isn&#8217;t balanced, and it shouldn&#8217;t be</h2><p>I&#8217;ve been describing this as a scale, value on one side, cost on the other, and I want to break that on the way out, because a scale implies the two sides start even. They don&#8217;t.</p><p>The thing you already have is paid for. In my case the thing I already had was ~5 years of knowing exactly how a Go consumer behaves when it&#8217;s unhappy at scale. The learning curve was behind me, the failure modes were boring and documented in my own head, and I threw all of that away for less code and no extra deploy. The new thing gets charged full price, <em>and</em> it gets charged later, and here it got charged to the one person who&#8217;d explicitly warned himself not to do it.</p><p>So the burden of proof sits on the change. Not because new things are bad, and not because I&#8217;m precious about the stack. But because the cost of the new thing is systematically underestimated by exactly the person most excited about it, and I&#8217;ve now been that person and that person&#8217;s only victim in the same three weeks. The value is loud and early. The cost is quiet and late.</p><p>Ask what it&#8217;s worth. Then ask who&#8217;s paying, and when. And if your gut already answered, at least make it argue with you before you overrule it.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://www.sperg.dev/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Failing Closed is a reader-supported publication. To receive new posts and support my work, consider becoming a free or paid subscriber.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[I built a harness that argues back]]></title><description><![CDATA[A dev harness for Claude Code, and the case for how it's built. Every claim here points at a running system, not a whiteboard.]]></description><link>https://www.sperg.dev/p/i-built-a-harness-that-argues-back</link><guid isPermaLink="false">https://www.sperg.dev/p/i-built-a-harness-that-argues-back</guid><dc:creator><![CDATA[Pat]]></dc:creator><pubDate>Sun, 19 Jul 2026 23:04:16 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!xe_p!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd0106609-0e83-41c4-9c0c-a9e5ea473f4f_500x500.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A while ago I asked it to add a billing settings page. Nothing crazy - show a user their plan, and let them update a payment method. One view, and one new API call. This distills out to a <em>feature request</em>. So I did what most devs do these day - I asked Claude to make the needful. What Claude came back with had already pulled in a security review I never asked for. Payment data was in play, so the harness treated a security pass as mandatory, regardless of whatever I&#8217;d <em>called</em> the work. And, it was right, and I hadn&#8217;t thought about it in that moment.</p><p>And that&#8217;s the whole ~thing in one moment. I spent a good while building a dev harness for Claude Code, that I call <code>core-dev</code>, and the part I keep wanting to talk about <strong>isn&#8217;t the feature list</strong>. It&#8217;s that the thing has opinions, that I codified and force it exert, and it&#8217;s built so those opinions are <strong>expensive to ignore</strong>. It loads the skills a turn needs before I ask (a lot more reliably than out of the box Claude does). It blocks a commit of code nobody reviewed, or a secret I didn&#8217;t notice I&#8217;d staged. The judgment calls it leaves to me; the mechanical ones it takes off the table. Most of the time it stays out of the way, and when it doesn&#8217;t, it&#8217;s usually because I was about to do something dumb.</p><p>I&#8217;m writing this series because every one of those behaviors is a <em>design argument</em>, and I&#8217;d rather defend the arguments than document the buttons. It&#8217;s also the one thing most agent-tooling posts don&#8217;t offer right now: these <strong>aren&#8217;t</strong> predictions about how you should build a harness, they&#8217;re <em>claims with a running system behind them</em>. I built the thing, I&#8217;ve been wrong inside it plenty, and it pushes back on me in ways I want to show you.</p><p>You don&#8217;t need my plugin for any of this to make sense, I hope.. The questions underneath are the ones you hit anywhere an agent does the work - where a rule should actually live, what you let a model decide, what you never trust it with, and how you stop the agent that writes code from also being the one that runs it. I came down somewhere specific on each, and the posts are me arguing for where I landed.</p><p>Here&#8217;s the map, in order. I&#8217;ll update links to new posts as I get them published.</p><p>Cheers!</p><div><hr></div><p><strong>Make the right thing the default, and the wrong thing loud.</strong> You get better behavior by reducing the cost of the &#8220;right path&#8221;, not by forbidding the wrong one (hey, sometimes I just feel like stabbing myself in the eye, okay?)</p><p><strong>Adherence is a budget.</strong> Every rule you write taxes every other rule, so route each one to the cheapest mechanism that will actually enforce it.</p><p><strong>Plan, build, review.</strong> One feature walked from the first prompt to an open MR, with every guardrail firing in order. This is the guided tour, if you want the whole shape in one read.</p><p><strong>A number is not a fact.</strong> A measurement isn&#8217;t <em>mechanical</em> just because it has a decimal point. When to gate, and when to \shrug.</p><p><strong>The opinion with a decimal point.</strong> I built a check that looked like signal, measured it against reality, watched it predict nothing, and shipped the negative result straight into production.</p><p><strong>If it isn&#8217;t written to disk, it didn&#8217;t happen.</strong> Just like PCAPs (or it never happened). Every step starts from nothing, so the only memory that survives is what gets written to a file.</p><p><strong>No context holds both a shell and a pen.</strong> The one rule that is the security spine: the context that can write can&#8217;t run, and the one that can run can&#8217;t write.</p><p><strong>The harness that tests itself.</strong> It fails open at runtime and fails closed in CI, and pointing its own review at itself caught a real bug.</p><p><strong>Let the model decide only what it can&#8217;t get wrong.</strong> Hand the model the one judgment call it&#8217;s actually needed for, and wrap deterministic code around the rest.</p><p><strong>Lean into the gate.</strong> A denied action isn&#8217;t the tool being difficult. It&#8217;s naming the step you skipped.</p><p>New pieces go up about once a week. If that sounds like your kind of thing, subscribe and I&#8217;ll send each one as it lands.</p>]]></content:encoded></item><item><title><![CDATA[Don’t call us, we’ll call you]]></title><description><![CDATA[Multi-agent systems are work queues, not call graphs!]]></description><link>https://www.sperg.dev/p/dont-call-us-well-call-you</link><guid isPermaLink="false">https://www.sperg.dev/p/dont-call-us-well-call-you</guid><dc:creator><![CDATA[Pat]]></dc:creator><pubDate>Sat, 18 Jul 2026 00:32:25 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!wr8N!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first multi-agent thing I built was a DAG. Planner reads the issue and calls the executor. Executor writes code and calls the reviewer. Reviewer returns pass or fail, and on fail we loop back to the executor with the review attached. Three nodes, two edges, one retry. Pretty straight forward graph - I could draw it on a whiteboard. I could test every node in isolation. It worked.</p><p>Then it got a task where the <em>spec</em> was wrong.</p><p>The executor did exactly what it was told, the reviewer correctly said no, and the executor went back and did the same wrong thing again. Only, slightly differently.. Until the turn budget ran out. \sigh</p><p>Nobody was confused. Every node did its job. The problem was that the only edge out of the reviewer went to the executor, because when I drew the thing, why would there be an edge back to the planner? The planner had already planned. That was step one. We were past that.</p><p>And that&#8217;s the bug, and it isn&#8217;t in any of the nodes.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="https://substackcdn.com/image/fetch/$s_!wr8N!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!wr8N!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 424w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 848w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1272w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1456w" sizes="100vw"><img src="https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png" width="1024" height="559" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/ebab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:559,&quot;width&quot;:1024,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:631549,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://packet64.substack.com/i/207491686?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!wr8N!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 424w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 848w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1272w, https://substackcdn.com/image/fetch/$s_!wr8N!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Febab7ae9-3bab-4ba9-b467-c1e44c5c2d8e_1024x559.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>A script terminates when it runs out of steps. An agent <em>replans</em> when it runs out of steps.</h2><p>That&#8217;s the whole difference. Everything else is plumbing we&#8217;ve all built a dozen times: a queue, a worker pool, a state store, retries with backoff. If you&#8217;ve written a job runner, you have all the parts. What you&#8217;re missing is one loop boundary in a different place.</p><p>I want to be clear that the pipeline instinct isn&#8217;t wrong. It&#8217;s the correct first move. It&#8217;s legible, it&#8217;s cheap, and for most of what people call &#8220;agents&#8221;, it&#8217;s also the last move they should make. The pipeline stops being right at a specific moment, and it&#8217;s worth being able to name that moment rather than reaching for the loop (because <a href="https://thoughtsbyjoshmabry.substack.com/p/whats-in-a-loop">loops are what serious people build</a>).</p><h2>The call graph is the problem</h2><p>Here&#8217;s the reframe that took me an embarrassingly long time to wrap my head around: <strong>the agents never call each other</strong>.</p><p>That&#8217;s literally it - if there&#8217;s one thing you take away from this post, it&#8217;s that. Internalize it.</p><h3>The Misnomer</h3><p>&#8220;Agent-to-agent&#8221; is a misnomer that smuggles an architecture in through the name. It sounds like a protocol between peers, right? </p><p>Well, it&#8217;s not. It&#8217;s agent, then state, then agent. Nobody&#8217;s phone rings. We&#8217;re not passing notes in math class - each node reads from a shared state, writes an artifact back, and a <em>dispatcher</em> decides who runs next.</p><p>And thus, here we are . It&#8217;s not a DAG - it&#8217;s a work queue with a scheduler. Not so scary now, right? The only interesting difference is that one worker type is allowed to enqueue new work, and its enqueue decision is a model output instead of a branch you wrote at 2am on a Tuesday six weeks ago after a few beers (not unlike this post).</p><p>That&#8217;s what the &#8220;edges&#8221; are, by the way. Every edge in your DAG is a decision frozen at authoring time. <code>if review_failed: retry()</code> is a guess about a situation you had not yet seen, made by a person who had less information than the system will have at the moment it matters. My reviewer-to-executor edge was a guess that review failures mean the code is wrong. Usually true, yes. But .. not always true. And the DAG has no way to find out, because the guess is load-bearing structure.</p><p>Delete the edges and the decision moves to runtime, where the information actually is.</p><h2>Four node types, and one rule for placing them</h2><p>Four kinds of node: planner, executor, verifier, reviewer. Their signatures tell you most of what you need, and the rule for placing them is simple.</p><blockquote><p><strong>If a node&#8217;s output is a decision, it&#8217;s frontier. </strong></p><p><strong>If it&#8217;s an artifact with a deterministic check, it&#8217;s the cheap model.</strong></p></blockquote><p>Decisions aren&#8217;t testable. That&#8217;s not a limitation to engineer around, it&#8217;s the criterion. &#8220;Should we retry or decompose?&#8221; doesn&#8217;t have an assert. But &#8220;Does <code>pytest tests/test_auth.py::test_expired_token</code> exit zero?&#8221; does. Route on that property, and the split falls out of the architecture instead of being a cost optimization you bolted on afterward.</p><h3>Planner: <code>state -&gt; [task]</code></h3><p>The only node with agency. It reads the entire history and decides what happens next, which makes it the one place in the system where &#8220;it depends&#8221; is an acceptable answer.</p><p>Two things people get wrong here. It runs on empty queue, not on schedule, and not on failure. There&#8217;s no <code>on_error: replan()</code> hook, because replanning isn&#8217;t an error path, it&#8217;s the normal path that happens to also cover errors. And it doesn&#8217;t plan the whole job. It plans the next chunk, which is usually one task, occasionally three, and never seventeen. A planner that emits seventeen tasks has quietly turned back into a DAG author, except now the DAG is written by something with worse judgment than you and no ability to draw it on a whiteboard.</p><p>Use the best model you can afford. This is where the money goes.</p><h3>Executor: <code>task -&gt; artifact</code></h3><p>Takes one task, produces one artifact. Makes no decisions, routes nothing, and never gets asked what should happen next.</p><p>Cheapest model that can hold a tool loop without falling over, which is a lower bar than it was a year ago and is the entire subject of the follow-up post. I&#8217;m a huge fan recently of <a href="https://huggingface.co/protoLabsAI/Ornith-1.0-35B-FP8">protoLabs' quant of Ornith 1.0 35B @FP8</a></p><h3>Verifier: <code>artifact -&gt; verdict</code></h3><p>The one I&#8217;d tattoo on people: <strong>the verifier is code.</strong> Sear this into your memory. It is not a model. Not a model with a really good prompt. Not a model with a really good prompt and structured output. <em>It. Is not. A model!</em></p><p>The instant the verifier is a model, you&#8217;ve rebuilt self-assessment, and self-assessment is the thing the whole apparatus exists to avoid. Done means verified <em>deterministically</em> or it doesn&#8217;t mean anything. An executor that grades its own homework is a very expensive way to generate confident nonsense, and it will do it politely, in JSON, with a <code>"confidence": 0.946</code>.</p><p>What this looks like in practice is unglamorous, and that&#8217;s the point. A shell command and an expected exit code. A schema validation. A diff that has to touch a file in the allowed set. <code>grep -c</code> on a log. If you can&#8217;t write the check, you don&#8217;t have an acceptance criterion, you have a vibe, and the planner shouldn&#8217;t have emitted that task in the first place.</p><h3>Reviewer: <code>artifact -&gt; judgment</code></h3><p>Reads artifacts that already passed the gate and forms an opinion about them. Use a frontier model, because opinions are decisions wearing a different smoking jacket.</p><p>A judgment is not a verdict and it&#8217;s worth keeping the words apart. A verdict is mechanical: the test passed, the build is green, the schema validated. It&#8217;s a fact, and code produces it. A judgment is an opinion, and opinions come in flavors: the fix mutes the symptom instead of curing it, the abstraction won't survive the next feature, the code is technically correct and morally a crime. Both are useful. Only one of them is allowed to gate anything.</p><p>The reviewer doesn&#8217;t route either. It records, same as everyone else, and the planner reads the record on the next tick and decides whether &#8220;morally a crime&#8221; is worth another three tasks or whether we ship it and open an issue.</p><h2>The loop</h2><p>Take a look at this conceptual python. </p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;233cb788-fd1b-4330-9888-ca89119ac3b3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">def run(goal, budget):
    state = State(goal=goal)
    queue = deque()

    while not goal_satisfied(state):
        if budget.exhausted():
            raise BudgetExhausted(state.summary())

        if not queue:
            tasks = plan_next_tasks(state)
            if not tasks:
                raise NoViablePlan(state.summary())
            queue.extend(tasks)

        task = queue.popleft()
        worker = worker_for(task.capability)
        artifact = worker.run(task, state.context_for(task))
        verdict = verify(artifact, task.acceptance)

        state.record(task, artifact, verdict)
        budget.charge(worker.cost)

    return state
</code></pre></div><p>Now look at what isn&#8217;t there.</p><p>There are no edges. There&#8217;s no <code>if verdict.failed: retry()</code>. There&#8217;s no routing table, no conditional handoff, no state machine enum with <code>AWAITING_REVIEW</code> in it. When <code>verify</code> fails, that just gets recorded. The queue drains. The loop comes back around, finds the queue empty, and asks the planner what to do about a state that now contains a failed verification. The planner might retry it. It might decompose it into three smaller tasks. It might notice the spec was wrong, which is the thing my DAG couldn&#8217;t do at any price.</p><p>The retry loop is emergent, not coded. That&#8217;s the self-driving property, and it costs exactly one <code>if not queue:</code>.</p><p>I find this genuinely hard to hold onto. Every time I read this loop I want to add an edge. Some case comes to mind, the reviewer rejects on style versus correctness and surely those should route differently, and my hand goes to the keyboard. Don&#8217;t. That&#8217;s a decision, decisions are the planner&#8217;s job, and the planner has strictly more information at tick 12 than you have right now.</p><h2>State is the architecture</h2><p>Two properties, and both of them are load-bearing.</p><p><strong>Append-only, not a mutable blackboard.</strong> The planner&#8217;s next decision is a function of history. Overwriting is amnesia. If tick 4 recorded &#8220;tried the obvious fix, test still red&#8221; and tick 9 overwrites it, the planner at tick 10 will happily try the obvious fix. You also get replay for free, which matters the first time one of them does something whack at 3am and you need to know whether the planner was wrong or the state was.</p><p><code>context_for(task)</code><strong> is a projection, not a dump.</strong> Each worker sees what its job needs. The executor gets the spec, the file paths, the test command. It does not get the planner&#8217;s reasoning, the reviewer&#8217;s prior judgments, or the transcripts of its five siblings.</p><p>This is where most implementations die, and it dies in disguise. You dump full state into every call because it&#8217;s easy and it&#8217;s correct-<em>ish</em>, and by tick four you&#8217;re feeding the executor a novel. Quality degrades. It feels like a model problem. You go swap models. It&#8217;s not a model problem, it&#8217;s that you handed a worker fifty thousand tokens of other people&#8217;s thinking and asked it to find the one line that was actually its assignment.</p><h2>Stopping</h2><p>Three mechanisms. You need all of them, and they fail differently.</p><p><code>goal_satisfied</code> is a predicate over recorded verifications. Never a model announcing success. Same reasoning as the verifier, higher stakes, because this one&#8217;s the exit.</p><p>Budget exhaustion raises, and it raises with a state summary, not just a log line. Something a human can read to find out what happened.</p><p>No-progress detection: if the state hash hasn&#8217;t moved in N ticks, kill it and escalate. This is the one people skip and it&#8217;s the one that saves you money, because a planner stuck in a loop of semantically-identical tasks will happily burn every token you&#8217;ll give it while producing state that&#8217;s byte-identical tick over tick.</p><p>The principle underneath all three: an agent that can&#8217;t finish has to say so <strong>loudly</strong>. Silent success is the worst failure mode you can ship, because it&#8217;s the one that ships. A crash gets fixed on Tuesday. A green checkmark on a job that quietly did nothing gets found in November.</p><h2>Watching it work: incident to patch</h2><p>Alert fires. Goal: figure out what borked and fix it.</p><p><strong>Tick 1.</strong> Queue&#8217;s empty, so the planner runs. It reads the alert, the logs, and a metrics window, and emits one task: write a failing test that demonstrates the reported behavior. Not &#8220;fix the bug.&#8221; The planner doesn&#8217;t know what the bug is yet and it isn&#8217;t pretending to.</p><p><strong>Tick 2.</strong> Executor takes it. Reads the code, writes a test, runs it. Artifact: a diff containing one new test. Verifier is a shell command: does the suite fail, and does it fail in the new test rather than somewhere else? Say it passes instead, meaning the test went green immediately. Recorded as a failed verification. Not an error. A fact.</p><p><strong>Tick 3.</strong> Queue&#8217;s empty again, planner runs again, and now it&#8217;s reading a state that says &#8220;our first theory of the bug produced a test that passes.&#8221; That&#8217;s information. It emits a different task: check whether the alert is even real, or whether the metric threshold moved.</p><p>The DAG I would have drawn cannot do tick 3. It would have taken the passing test as a green light and gone on to patch a bug that wasn&#8217;t there.</p><p>Note the gate sitting between the repro executor and the patch executor. Two executors back to back with nothing in between compounds error: the second one builds on the first one&#8217;s work with no independent check that the first one&#8217;s work was real. The verifier isn&#8217;t ceremony, it&#8217;s the thing that keeps the second executor from inheriting a hallucination as a premise.</p><p>Run the same alert twice and you&#8217;ll get different task sequences, because the planner sees different state. That&#8217;s the payoff and it&#8217;s also the part that makes these systems annoying to test, which is a real cost and I&#8217;m not going to pretend otherwise.</p><h2>When you shouldn&#8217;t build this</h2><p>Most of the time. Despite the echo chamber of modern .. life.</p><p>If the task shape is known, the steps are fixed, and failure means stop, just write the damn script. Tell your director to shove it and punch them in the face. It&#8217;ll be faster, it&#8217;ll cost less, you can reason about it, and when it breaks you&#8217;ll know why in about ninety seconds. Nobody has ever been paged at 3am because their bash script had insufficient agency, or it hallucinated a <code>glob</code> as <code>rm -rf /</code> in a privileged environment.</p><p><strong>The loop earns its complexity in exactly one condition</strong>: <em>the task space is open and partial failure is expected and informative.</em> Open means you cannot enumerate the paths ahead of time. Informative means a failure at tick 2 should change what happens at tick 3. If failures don&#8217;t change the plan, you don&#8217;t need a planner, you need a retry decorator and a good night&#8217;s sleep.</p><p>Most things sold as agents are scripts wearing a hat. That&#8217;s frequently correct. The hat is the problem, not the script.</p><h2>The tell</h2><p>Here&#8217;s how I know now, before I write anything: I ask what happens when the plan is wrong.</p><p>If the answer is &#8220;it fails and I look at it,&#8221; write the script. Genuinely. That&#8217;s a fine answer and it&#8217;s the right answer more often than the discourse suggests.</p><p>If the answer is &#8220;it needs to notice and try something else,&#8221; you don&#8217;t want a better DAG. You want a loop with a planner in it, and you want the courage to leave the edges out, which is harder than it sounds because the edges feel like engineering and the empty space feels like you forgot something.</p><p>You didn&#8217;t. That&#8217;s the design.</p>]]></content:encoded></item></channel></rss>