<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Walkthroughs · The Durability Curve</title><description>Every piece in Walkthroughs, in full.</description><link>https://durabilitycurve.com/</link><item><title>The Graph Looks Right. The Merge Is Where It Breaks.</title><link>https://durabilitycurve.com/blog/where-agents-disagree/</link><guid isPermaLink="true">https://durabilitycurve.com/blog/where-agents-disagree/</guid><description>A multi-agent graph can lose a verdict with no error and nothing turning red. The bug is one line in the state schema.</description><pubDate>Sun, 30 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Three agents disagreed, and the gate I built approved the release anyway. The framework had caught the conflict; the bug began the moment I made the error go away. Here is the one line that did it, and how to see it in your own graph.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Verified end to end on langgraph 0.6.11.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p data-cover-after=&quot;&quot;&gt;I built a release gate out of agents. An orchestrator fanned out to three reviewers: one read the test run, one read the error metrics, one read the changelog. Each came back with a verdict, ship or hold or roll back. A final node read the verdict and acted on it. I drew the graph, and it looked like every orchestrator-worker diagram you have ever seen. After one wiring error I thought I had fixed, I ran it again, and it worked.&lt;/p&gt;
&lt;p&gt;Then it approved a release that two of the three reviewers had rejected.&lt;/p&gt;
&lt;p&gt;There was no exception, and nothing turned red in the log. The graph had done exactly what I wired it to do, and what it did was take the ship branch. I went looking for the bug in the reviewer prompts, and they were fine. Then in the routing, and that was fine too. The verdicts were correct. Two of them said stop. The code that read them looked like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; state[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;decision&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;roll back&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    abort_release()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ship()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And &lt;code&gt;state[&quot;decision&quot;]&lt;/code&gt; was &lt;code&gt;&quot;ship it&quot;&lt;/code&gt;. One verdict was there, the other two were gone. That is the part that took me a while to accept: it was never three reviewers reaching a decision. It was three agents writing to one slot, and the code downstream read whichever write survived. I had never said who should own that slot, so something I had wired without noticing answered the question for me, and answered it wrong.&lt;/p&gt;
&lt;h2 id=&quot;the-picture-is-not-where-the-fault-is&quot;&gt;The picture is not where the fault is&lt;/h2&gt;
&lt;p&gt;Put the broken graph next to a correct one. Orchestrator at the top, three workers below it, a node at the bottom that makes the call. The two diagrams are the same boxes and arrows, and the fault is in neither of them. It is one line down in the state schema, a line you probably wrote without thinking about it, that decides what happens when three workers write the same slot. Ranjan Kumar put the general point better than I will, in a piece worth reading in full: the rule that resolves those writes “appears on no diagram, in no edge list, and in no type checker’s output,” and it is the only thing they all miss that decides what the state value actually is.&lt;sup&gt;&lt;a href=&quot;https://durabilitycurve.com/blog/where-agents-disagree/#user-content-fn-1&quot; id=&quot;user-content-fnref-1&quot; data-footnote-ref=&quot;&quot; aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; The difference between my two graphs lives there, in the schema, and the schema is what you have to read.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Two release gates drawn as identical boxes and arrows, with their State schemas side by side. The broken schema gives decision a last-write-wins reducer and two of three verdicts vanish silently; the safe schema gathers findings from all three and lets one owner write decision. The diagrams are the same; the schemas are not, and the schema is where the verdict is kept or lost.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;2000&quot; height=&quot;1424&quot; src=&quot;https://durabilitycurve.com/_astro/diagram-schema-is-the-picture-where-agents-disagree-2026-08-29.b-zuK7fh_Zzn981.webp&quot; &gt;&lt;/p&gt;
&lt;h2 id=&quot;the-error-was-the-invariant&quot;&gt;The error was the invariant&lt;/h2&gt;
&lt;p&gt;In LangGraph your state is a set of channels, and every channel has a rule for what happens when more than one node writes it in a single step. That rule is the whole game.&lt;/p&gt;
&lt;p&gt;Wire three parallel workers to write a plain channel, the way my release gate did, and run it. LangGraph stops you, at the exact key:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;InvalidUpdateError: At key &apos;decision&apos;: Can receive only one value per step.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Use an Annotated key to handle multiple values.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I read this as an inconvenience, which is the mistake. It is the one place the framework asked me a question I had not answered: who is allowed to write this field. Three writes arrived for one slot, and rather than pick a winner behind my back, it refused. Left here, the bug cannot ship, because the exception is holding an invariant I never wrote down.&lt;/p&gt;
&lt;p&gt;So I made the exception go away, the way the message suggests: add a reducer. One documented reducer pattern is &lt;code&gt;operator.add&lt;/code&gt;. On a &lt;code&gt;str&lt;/code&gt; channel it concatenates the three verdicts into &lt;code&gt;&quot;roll backship ithold&quot;&lt;/code&gt;, which is visible garbage you would catch. On a list channel it keeps all three, which is correct, and only a downstream &lt;code&gt;decision[0]&lt;/code&gt; quietly throws two away. Neither of those is the silent single-survivor I hit. To get that I reached past the docs for the smallest reducer that makes the error stop, keep the latest write:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; State&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;TypedDict&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    decision: Annotated[&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;lambda&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; old, new: new]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the trap, and it is worth being exact about where it comes from. LangGraph’s own error page shows the lossless &lt;code&gt;operator.add&lt;/code&gt;. But overwrite reducers like &lt;code&gt;lambda old, new: new&lt;/code&gt; are a familiar pattern too, in examples and in real code, so this is not one careless engineer. The framework refused, I reached for a familiar reducer to silence it, and I did it on a field where overwriting meant throwing away a verdict. The run went clean, the channel held one verdict, and the other two were gone with no error:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ python agent_graph_demo.py --trap&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;STEP 1 (plain channel): the framework refused -&gt; InvalidUpdateError: At key &apos;decision&apos;: Can receive only one value per step. Use an Annotated key to handle multiple values.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;STEP 2 (reducer added): no error. the decision channel now holds: &apos;hold&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;        the other two reviewers&apos; verdicts were silently dropped&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the idea I want you to keep, because it travels past LangGraph. A reducer answers one question: when two writes land on this field, how do they combine. It cannot answer the prior one: should two writes ever land on this field at all. That first question is about ownership, and the type does not answer it. A &lt;code&gt;str&lt;/code&gt; can be a log fragment or an authoritative verdict; an &lt;code&gt;int&lt;/code&gt; can be a counter, a balance, or a version number. The same type permits a merge that is right for one meaning and ruinous for another, because the safe concurrency policy depends on what the value means, and the meaning is not in the type. Last-write-wins itself is not the villain: it is right when overwrite is part of the field’s contract, and wrong when competing writes are evidence that has to be reconciled first. So the order matters: &lt;strong&gt;decide who owns a field before you decide how its writes merge.&lt;/strong&gt; For a decision, exactly one node owns it. The &lt;code&gt;InvalidUpdateError&lt;/code&gt; was enforcing the same-step half of that, no more than one write to this field in a single step, and I silenced the one guard I got for free. The other half, that no other node writes it in a later step, the error cannot see, and we get to it below.&lt;/p&gt;
&lt;p&gt;One point of precision, since a careful reader will want it. This is a deterministic super-step conflict, not a thread-level data race; LangGraph batches the writes from a step and applies the channel’s rule to them. And which verdict survives is not random: in this static fan-out on 0.6.11, the write from the node whose name sorts last wins, which I know only because I reordered the nodes and watched it change. It is stable across re-runs and it is an undocumented detail you should never build a decision on.&lt;/p&gt;
&lt;h2 id=&quot;the-graph-that-keeps-the-disagreement&quot;&gt;The graph that keeps the disagreement&lt;/h2&gt;
&lt;p&gt;Here is the same release gate, wired so ownership is explicit. The move is to separate two jobs that were fighting over one channel: gathering what the reviewers found, and deciding what that means. Gathering has many writers. Deciding has one.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;Verdict &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; Literal[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;ship it&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;hold&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;roll back&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; Finding&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;TypedDict&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    reviewer: &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    verdict: Verdict&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; State&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;TypedDict&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    findings: Annotated[list[Finding], operator.add]   &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# gather: many writers append&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    decision: Verdict                                  &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# decision: one owner writes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; reviewer&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(source):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; node&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(state):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;findings&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: [{&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;reviewer&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: source, &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;verdict&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: call_model(source)}]}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; node&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; decide&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(verdicts: list[Verdict]) -&gt; Verdict:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # the policy, named and owned. this one is any-veto by severity; swap it for&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # majority / unanimous / weighted / threshold as your gate actually needs.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;roll back&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; in&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; verdicts: &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;roll back&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;hold&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; in&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; verdicts:      &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;hold&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;ship it&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;EXPECTED&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;tests&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;metrics&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;changelog&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; writer&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(state):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    reviewers &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; [f[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;reviewer&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; f &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; state[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;findings&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;]]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; sorted&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(reviewers) &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; sorted&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;EXPECTED&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):   &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# missing or duplicate is not a ship&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;        return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;decision&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;hold&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;decision&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: decide([f[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;verdict&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;for&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; f &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; state[&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;findings&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;]])}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reviewers write concurrently to the same gather channel, and here the merge is legitimate: keeping every contribution is exactly what the field means. Concurrency was never the bug; a wrong ownership rule was. Then one node, and only one, reads all three findings and applies a policy that is written down where you can argue with it. Notice the policy is a veto by severity: one roll back overrides two ships, because that is the release rule I want. You can pick any rule you like. What matters is the split: gathering the evidence and ruling on it are different operations, so they get different channels, and the ruling has an owner. Ownership is only part of the gate. &lt;code&gt;decide&lt;/code&gt; assumes every reviewer reported, so a reviewer that drops out without a finding is a silent ship in a different coat, and one that reports twice can tip a majority or a weighted rule. The writer fails closed on both: it rules only when every expected reviewer reported exactly once, and anything else is a hold. Ownership says one node decides; completeness says everyone reported; uniqueness says each reported once. Run it with all three present and it reconciles three of three, on a decision a node made after seeing them.&lt;/p&gt;
&lt;p&gt;Two things a builder asks here. When you do not know how many reviewers you will have, &lt;code&gt;Send&lt;/code&gt; gives you dynamic fan-out, and the footgun is identical: I wired a &lt;code&gt;Send&lt;/code&gt; fan-out into a shared decision channel and it raised the same &lt;code&gt;InvalidUpdateError&lt;/code&gt;, so the same split fixes it. And swapping the stub for a real model is two lines, a &lt;code&gt;ChatAnthropic&lt;/code&gt; or &lt;code&gt;ChatOpenAI&lt;/code&gt; in place of the stub; the graph, the channels, and the ownership rule do not change, because the model is a node and the safety is in the wiring.&lt;/p&gt;
&lt;p&gt;Whether to split the work across agents at all is a separate question, and I argued it in &lt;a href=&quot;https://durabilitycurve.com/blog/your-multi-agent-system-is-an-org-chart/&quot;&gt;Your Multi-Agent System Is an Org Chart&lt;/a&gt;. This walkthrough assumes you have decided to split, and shows the line where the split silently is not real.&lt;/p&gt;
&lt;h2 id=&quot;read-it-off-your-own-schema-then-off-your-own-run&quot;&gt;Read it off your own schema, then off your own run&lt;/h2&gt;
&lt;p&gt;Your real graph has more than three channels, and you will not remember which carry decisions. You can have a script find the risky ones. Kumar ships one that inspects a compiled graph and reports each channel’s merge policy; the small version I use reads the &lt;code&gt;State&lt;/code&gt; schema itself:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;python audit.py            # or: from audit import audit_schema; audit_schema(MyState)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works because a fan-out cannot silently collide on a plain channel: the framework refuses that write out loud. The only place a fan-out’s writes merge without a word is a channel that carries a reducer, so the reducer channels are the whole fan-out surface, and the tool reads them off the annotation:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;audit of State&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  ! classify    findings       REDUCER (add)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  fan-out safe  decision       plain&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For each flagged channel, ask how its value gets read downstream. Does anything branch on it, or act on its value as one answer? Then it is a decision channel: give it one writer and no reducer. Is it only ever folded into an aggregate? Then it is a gather, and the reducer belongs there.&lt;/p&gt;
&lt;p&gt;That check is real, and it is only half. It is a reducer-surface audit: it finds where a merge is permitted. It does not prove a decision channel has exactly one writer, because which channels a node writes is a dictionary it returns at runtime, not a type you can read ahead of time; a node’s introspectable channels are its reads, not its writes. So a plain decision channel that two nodes write in sequence overwrites silently, no error, and the schema cannot see it. The second check is a runtime one: in a test, assert that across the whole run, every write to a decision channel came from the same one node. That holds even for a looping graph, where the owner may write many times but no one else may write at all. The first check finds where merges are allowed; the second finds where ownership is broken.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Two ways a decision channel is silently overwritten. On the left, the fan-out: three workers write it in one step, the type carries a reducer, and the schema audit catches it. On the right, in sequence: two nodes write it in different steps, the type is a plain str with nothing to flag, and only a runtime check catches it. The reducer makes the fan-out visible to the schema; the overwrite in sequence hides in a plain type, so the run is the only place to catch it.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;2000&quot; height=&quot;1000&quot; src=&quot;https://durabilitycurve.com/_astro/diagram-two-checks-where-agents-disagree-2026-08-29.CkCkdip3_Z1p17OB.webp&quot; &gt;&lt;/p&gt;
&lt;h2 id=&quot;cap-the-loop-count-the-cost-gate-the-write&quot;&gt;Cap the loop, count the cost, gate the write&lt;/h2&gt;
&lt;p&gt;Ownership is the idea to carry out of this. Three smaller safeguards finish a graph once you have made it safe on that.&lt;/p&gt;
&lt;p&gt;A loop with no exit does not run forever; LangGraph stops it with &lt;code&gt;GraphRecursionError&lt;/code&gt; at a default limit, 25 on the 0.6.11 this piece was built on and far higher on current releases. The reflex is to crank that limit up, which only buys a stuck agent more steps and a larger bill. Right-size it a little above your real longest path, and read a trip as “this agent is stuck,” not “this agent needs more room.”&lt;/p&gt;
&lt;p&gt;Fan-out is not free. Three parallel workers are three model calls, which the demo counts for you, and running them in parallel buys you wall-clock time and nothing on the bill, because you pay for every branch. None of the failures in this piece is fixed by a better model; they are decisions about ownership and cost that a smarter model makes faster, not safer.&lt;/p&gt;
&lt;p&gt;And the decision node, being the one whose output the graph acts on, is where a human gate belongs if you want one. LangGraph’s &lt;code&gt;interrupt()&lt;/code&gt; gives you that, with one caveat worth its own walkthrough: on 0.6.11, resuming needs a checkpointer, and on resume the node runs again from the top, so the irreversible work goes below the interrupt, not above it. That is a whole article; here it is enough to know the reliability budget belongs on the write, because the write is the part that is owned.&lt;/p&gt;
&lt;h2 id=&quot;most-of-the-time-do-not-build-a-graph&quot;&gt;Most of the time, do not build a graph&lt;/h2&gt;
&lt;p&gt;Most of this you avoid by not reaching for a graph. A single agent with tools and a step that compresses its own history handles more than people expect, and it avoids the fan-out and merge failure this walkthrough has been about, because there is nothing to fan out and nothing to merge. It can still loop, lose context, or run up a bill; it just cannot lose a verdict in a merge. Single prompt, then tools and retrieval, then a fixed workflow, then one agent, then several: climb a rung only when the one below it measurably fails.&lt;sup&gt;&lt;a href=&quot;https://durabilitycurve.com/blog/where-agents-disagree/#user-content-fn-2&quot; id=&quot;user-content-fnref-2&quot; data-footnote-ref=&quot;&quot; aria-describedby=&quot;footnote-label&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; A graph is a cost, and you pay it in exactly the channels this walkthrough has been about.&lt;/p&gt;
&lt;h2 id=&quot;what-to-do-this-week&quot;&gt;What to do this week&lt;/h2&gt;
&lt;p&gt;Take one graph you already run. Pass its &lt;code&gt;State&lt;/code&gt; schema to the audit, and for every reducer channel it flags, ask whether anything downstream acts on it as a single value; give each one that does a single writer. Then, on purpose, wire a fan-out into a plain decision channel and watch LangGraph refuse it, add a last-write-wins reducer to make the error stop, and watch two verdicts disappear. The next time that sequence happens, let it be in a test you wrote and not a release you shipped.&lt;/p&gt;
&lt;p&gt;My release gate has one decision owner now. The three reviewers still disagree; they simply do not get to settle it by racing. The reducer was never meant to decide who was right.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;The three programs here, &lt;code&gt;verify.py&lt;/code&gt;, &lt;code&gt;agent_graph_demo.py&lt;/code&gt;, and &lt;code&gt;audit.py&lt;/code&gt;, run with no API key and are yours to keep: &lt;a href=&quot;https://durabilitycurve.com/tools/agent-graph/&quot;&gt;durabilitycurve.com/tools/agent-graph&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;And if you want the paper version to run on a graph you already have, The State Channel Audit is a free three-step worksheet, classify every channel, guard every decision, run the two checks. &lt;a href=&quot;https://durabilitycurve.com/tools/agent-graph/State-Channel-Audit-SCA-01.pdf&quot;&gt;Download the worksheet (PDF)&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;section data-footnotes=&quot;&quot; class=&quot;footnotes&quot;&gt;&lt;h2 class=&quot;sr-only&quot; id=&quot;footnote-label&quot;&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li id=&quot;user-content-fn-1&quot;&gt;
&lt;p&gt;Ranjan Kumar, &lt;a href=&quot;https://ranjankumar.in/langgraph-reducers-concurrent-state-writes&quot;&gt;LangGraph Reducers Are a Concurrency Policy&lt;/a&gt;, 27 July 2026, reached all of this before I did: the reducer as a concurrency policy, the review-surface blind spot, the sequential silent overwrite, and a compiled-graph merge-policy auditor. His piece is the one to read for the general case. What is new here is the ownership-before-merge-policy framing, the worked release-gate failure, and a runnable no-key demo. &lt;a href=&quot;https://durabilitycurve.com/blog/where-agents-disagree/#user-content-fnref-1&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 1&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-2&quot;&gt;
&lt;p&gt;The architecture-level sources behind the single-writer rule and the orchestrator-worker pattern are Cognition’s &lt;a href=&quot;https://cognition.com/blog/dont-build-multi-agents&quot;&gt;Don’t Build Multi-Agents&lt;/a&gt; (2025) and its 2026 follow-up &lt;a href=&quot;https://cognition.com/blog/multi-agents-working&quot;&gt;Multi-Agents: What’s Actually Working&lt;/a&gt;, which finds these systems work best when writes stay single-threaded and the extra agents add intelligence rather than actions; and Anthropic’s &lt;a href=&quot;https://www.anthropic.com/engineering/building-effective-agents&quot;&gt;Building Effective Agents&lt;/a&gt; (2024) and &lt;a href=&quot;https://www.anthropic.com/engineering/multi-agent-research-system&quot;&gt;multi-agent research write-up&lt;/a&gt; (2025). On how these systems fail in the field, the &lt;a href=&quot;https://arxiv.org/abs/2503.13657v2&quot;&gt;MAST study&lt;/a&gt; (Cemri et al., 2025) finds specification and system design the largest of three roughly balanced failure categories, and the authors trace many failures to system design rather than model limits alone. &lt;a href=&quot;https://durabilitycurve.com/blog/where-agents-disagree/#user-content-fnref-2&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Your Tests Pass. So What?</title><link>https://durabilitycurve.com/blog/your-tests-pass-so-what/</link><guid isPermaLink="true">https://durabilitycurve.com/blog/your-tests-pass-so-what/</guid><description>A green suite only proves your agent cleared the gate. Mutation testing shows whether the tests behind it can bite.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the second walkthrough in a series. &lt;a href=&quot;https://durabilitycurve.com/blog/never-let-claude-code-tell-you-its-done/&quot;&gt;The first&lt;/a&gt; built a gate: a Stop hook that will not let Claude Code end its turn while any test is failing, so it cannot call a job done on a red suite. This one asks whether the tests behind that green are worth passing, and you do not need to have read the first to follow along.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you will do:&lt;/strong&gt; measure how many real bugs your test suite can actually catch. In the worked example, a green suite catches just one planted bug in ten; among the nine it misses is a discount that quietly becomes a surcharge. A tighter case shows the sharper trap: a test can cover every line of a function and still notice nothing. You will watch both scores land, then hand Claude Code the holes and make it close them. About twenty-five minutes for the worked example; a first pass on your own repository takes your whole suite’s runtime once for every mutant, so start with your smallest tested file.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; you gate Claude Code, or any coding agent, on green tests, and the suite has been reassuringly green ever since.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who should skip it:&lt;/strong&gt; if you already run mutation testing, this is your Tuesday. Step 3, turning your existing survivor backlog into an agent work order, may not be.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Never written a test?&lt;/strong&gt; Your version of the whole method is at the end: ten minutes, three planted errors, no code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You need:&lt;/strong&gt; Python 3 (3.9 or later, standard library only, nothing to install). Claude Code for step 3; steps 1 and 2 run without it.&lt;/p&gt;
&lt;p&gt;Then: when it still goes wrong, proving it on code you ship, and a version if you never write code.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-cover-after=&quot;&quot;&gt;I broke a production module of mine on purpose, 105 small ways, one at a time, and ran its test suite after every break. The suite is real: 21 regression tests, all green, guarding an 822-line file my own automation depends on. The tests noticed 38 of the 105 breaks. The other 67, one of them on a line the suite executes every single run, would have shipped without a sound.&lt;/p&gt;
&lt;p&gt;That experiment is the whole method, and it answers a question a passing test suite cannot. The gate proves the tests pass. It cannot prove the tests are worth passing; a test that asserts nothing sails straight through it. And if your instinct is to have a second agent check the tests, then a third to check the second, the regress ends here instead, at an experiment rather than another opinion: break the code on purpose, and see whether the alarm rings.&lt;/p&gt;
&lt;h2 id=&quot;watch-a-test-do-nothing&quot;&gt;Watch a test do nothing&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;(You know why an assert-nothing test passes? Skip to step 2; this section is the on-ramp.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here is the toy calculator shop from last time, one walkthrough later. Its &lt;code&gt;add&lt;/code&gt; works, and the suite is two small tests. The gate is green. (If you download the companion folder, &lt;code&gt;calc.py&lt;/code&gt; also carries a new pricing function, which is step 2’s problem, and &lt;code&gt;check.sh&lt;/code&gt;, the last walkthrough’s gate script, along for the ride. Leave both alone for now.)&lt;/p&gt;
&lt;p&gt;&lt;code&gt;calc.py&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; add&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(a, b):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; a &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; b&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;test_calc.py&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; unittest&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; calc &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; add&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; TestAdd&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;unittest&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;TestCase&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_basic&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;        self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(add(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_zero&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;        self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(add(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# ...the unittest.main() entrypoint is unchanged below&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save both files in one folder and open your terminal there; the import &lt;code&gt;from calc import add&lt;/code&gt; only resolves if they sit together. (Downloading the folder in step 2 does this for you.) One of these two tests is doing almost all of the work, and one of them is doing almost none. You can find out which in thirty seconds. Break &lt;code&gt;add&lt;/code&gt; on purpose (&lt;code&gt;return a - b&lt;/code&gt;, the classic bug) and run the suite, &lt;code&gt;python3 -m unittest&lt;/code&gt;, the same command the gate runs: &lt;code&gt;test_basic&lt;/code&gt; fails. Now, with the code still broken, run only the other test:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ python3 -m unittest test_calc.TestAdd.test_zero&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;----------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Ran 1 test in 0.000s&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;OK&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Green, on code that subtracts. &lt;code&gt;add(0, 0)&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; whether add adds, subtracts, or multiplies, so &lt;code&gt;test_zero&lt;/code&gt; approves all three. It has one way to fail, and almost no wrong version of the code triggers it. Note what your coverage tool would say about it: &lt;code&gt;test_zero&lt;/code&gt; executes every line of &lt;code&gt;add&lt;/code&gt;, one hundred per cent, top marks. Coverage measures whether tests run the code. It has no opinion on whether they would notice anything. &lt;code&gt;test_zero&lt;/code&gt; has sat in that suite looking exactly as load-bearing as &lt;code&gt;test_basic&lt;/code&gt;, and it would wave the classic bug straight through the gate on its own.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A test you have never watched fail is not yet a test.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Test-driven veterans have said a version of that for twenty years: never trust a test you haven’t seen fail. The discipline is the same, and the move is the one you just made: break the code on purpose, and the test either notices or it does not. Once, by hand, takes thirty seconds. Every plausible break is a script. Fix &lt;code&gt;add&lt;/code&gt; back before you move on; the script is next.&lt;/p&gt;
&lt;h2 id=&quot;count-what-your-suite-can-see&quot;&gt;Count what your suite can see&lt;/h2&gt;
&lt;p&gt;The shop grew this week. Claude Code added a pricing function, and the suite is green, which is all the gate checks. Here is the function:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; discounted_total&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(price, quantity, discount_percent):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;    &quot;&quot;&quot;Total cost of an order, in pounds.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;    Orders of 10 or more items get discount_percent knocked off.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;    &quot;&quot;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    total &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; price &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; quantity&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; quantity &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 10&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        total &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; total &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; discount_percent &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 100&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; round&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(total, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Money code. A boundary, a formula, a rounding rule: three places to be quietly wrong. (Yes, real tills count integer pence rather than floating-point pounds; hold that thought, it returns at the end.) The question you cannot answer by reading the green gate: if one of those went wrong tonight, would any test notice?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;mutate.py&lt;/code&gt; answers it by brute honesty. It is a transparent teaching instrument, 180 lines of standard library you can read top to bottom, built to make the mechanism visible on one file rather than to replace a mature framework. Its whole engine is the dozen classic ways code goes wrong that it knows how to plant, one character at a time:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;OP_SWAPS&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ast.Add: ast.Sub,    &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# a + b   -&gt;  a - b&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ast.Mult: ast.Div,   &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# a * b   -&gt;  a / b&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ast.GtE: ast.Gt,     &lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# &gt;=      -&gt;  &gt;   (the off-by-one at every boundary)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ast.Eq: ast.NotEq,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ast.And: ast.Or,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # ...the reverse of each, plus &amp;#x3C; and &amp;#x3C;=, a dozen swaps in all,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # and integer nudges: 10 -&gt; 11&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For each place in your file where one of those swaps applies, it makes that one change (a &lt;em&gt;mutant&lt;/em&gt; of your code), runs your whole suite, restores the file, and records the verdict. Before any of that it runs your suite once on the untouched code, timed: a red baseline gets refused outright, because against a suite that is already failing, every verdict is noise. Then the two outcomes. A mutant that makes at least one test fail is &lt;em&gt;killed&lt;/em&gt;: the alarm rang. A mutant that leaves every test green &lt;em&gt;survived&lt;/em&gt;: that exact bug could ship tonight.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://durabilitycurve.com/tests-worth-passing.zip&quot;&gt;Grab the folder&lt;/a&gt;, hosted on my own site; it is short, dependency-free Python you can read before you run it. Unzip it and open your terminal inside the &lt;code&gt;tests-worth-passing&lt;/code&gt; folder; every command below runs from there, no setup.&lt;/p&gt;
&lt;p&gt;The four working files are &lt;code&gt;calc.py&lt;/code&gt;, &lt;code&gt;test_calc.py&lt;/code&gt;, &lt;code&gt;mutate.py&lt;/code&gt;, and &lt;code&gt;check.sh&lt;/code&gt; (last walkthrough’s gate, along for the ride); a README and the agent’s finished tests sit alongside them and need nothing from you. When you are ready to point this at your own code, the folder’s &lt;code&gt;AUDIT.md&lt;/code&gt; carries the reusable version: the audit protocol, a survivor-triage worksheet, and the command for your language.&lt;/p&gt;
&lt;p&gt;Now stop before you run it, and put a number down. Your suite is green and it passes. Of ten deliberate breaks to this file, how many do you think it catches? Hold that guess against the result:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ python3 mutate.py calc.py&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;10 mutants of calc.py · suite: python3 -m unittest -q&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;baseline: suite green in 0.2s (per-mutant timeout 60s)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  1 KILLED    line 2: a + b  -&gt;  a - b&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  2 SURVIVED  line 10: price * quantity  -&gt;  price / quantity&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  3 SURVIVED  line 11: quantity &gt;= 10  -&gt;  quantity &gt; 10&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  4 SURVIVED  line 11: 10  -&gt;  11&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  5 SURVIVED  line 12: total * (1 - discount_percent / 100)  -&gt;  total / (1 - discount_percent / 100)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  6 SURVIVED  line 12: 1 - discount_percent / 100  -&gt;  1 + discount_percent / 100&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  7 SURVIVED  line 12: 1  -&gt;  2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  8 SURVIVED  line 12: discount_percent / 100  -&gt;  discount_percent * 100&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  9 SURVIVED  line 12: 100  -&gt;  101&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 10 SURVIVED  line 13: 2  -&gt;  3&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Score: 1/10 killed, 9 survived.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Every SURVIVED line is a change to your code that your whole test suite&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;cannot tell from the version you meant to write.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sixth mutant is the one to read twice. &lt;code&gt;1 - discount_percent / 100&lt;/code&gt; became &lt;code&gt;1 + discount_percent / 100&lt;/code&gt;: &lt;strong&gt;a customer’s 20 per cent discount becomes a 20 per cent surcharge, and the gate stays green.&lt;/strong&gt; The third is the boundary: &lt;code&gt;&gt;=&lt;/code&gt; became &lt;code&gt;&gt;&lt;/code&gt;, the customer buying exactly ten items loses the discount you promised them, green. Nine ways for money code to be wrong, and the suite from step 1 sees none of them, because nothing in it ever calls the new function. The gate never lied. “The tests pass” was true every time it said so. It just was not the thing you needed to be true.&lt;/p&gt;
&lt;p&gt;A fair objection: nothing tests that function, so a plain coverage report would have flagged it too, without any of this mutant theatre. True, and if that were all mutation testing found, you would not need it. So run the case coverage cannot see. Delete &lt;code&gt;test_basic&lt;/code&gt;, keep only &lt;code&gt;test_zero&lt;/code&gt;, and run the instrument again:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ python3 mutate.py calc.py&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;10 mutants of calc.py · suite: python3 -m unittest -q&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;baseline: suite green in 0.1s (per-mutant timeout 60s)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  1 SURVIVED  line 2: a + b  -&gt;  a - b&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  ...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Score: 0/10 killed, 10 survived.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The subtraction bug now survives, on a function with one hundred per cent line coverage. Your coverage dashboard reports &lt;code&gt;add&lt;/code&gt; fully tested; the instrument reports that no test would notice if it subtracted. Coverage tells you the code ran. A kill tells you a lie got caught. They are different instruments, and only one of them is measuring what you care about.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The same function can be one hundred per cent covered and zero per cent detected.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is not a toy-only failure, and it is the shape to hold on to. On the 822-line module I opened with, the survivor that stung most was exactly this: a covered line, inside a function the suite runs every single time, that no assertion actually pins. Put &lt;code&gt;test_basic&lt;/code&gt; back and carry on.&lt;/p&gt;
&lt;p&gt;One reading note before you run it on anything you love: &lt;strong&gt;killed is the good outcome.&lt;/strong&gt; Every killed mutant is a bug class your suite would catch, so on this one screen a &lt;code&gt;KILLED&lt;/code&gt; is the line you are hoping for, even though the word sounds like something broke.&lt;/p&gt;
&lt;p&gt;This move is called mutation testing, and it is older than most of the code you have ever shipped. Breaking one character at a time is a fair stand-in for the elaborate bugs real code grows, because of the &lt;em&gt;coupling effect&lt;/em&gt;: catch the small, dumb faults and you catch the large subtle ones as a by-product.&lt;sup&gt;&lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fn-1&quot; id=&quot;user-content-fnref-1&quot; data-footnote-ref=&quot;&quot; aria-describedby=&quot;footnote-label&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; The nine survivors here are the ones your suite missed, and every one of them is now a job you can hand to the thing that wrote the tests.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;Animated terminal: the mutation run. A green, passing suite catches one of ten planted breaks; nine survive in red. The survivors become a work order for Claude Code, and the same run, re-run, catches ten of ten.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;1720&quot; height=&quot;968&quot; src=&quot;https://durabilitycurve.com/_astro/anim-mutation-run-2026-07-02.B2JxQD-c_Z1nx5W3.webp&quot; &gt;&lt;/p&gt;
&lt;h2 id=&quot;hand-the-survivors-to-the-agent&quot;&gt;Hand the survivors to the agent&lt;/h2&gt;
&lt;p&gt;Nine survivors is a work order, addressed to the thing that wrote the tests. Paste the nine &lt;code&gt;SURVIVED&lt;/code&gt; lines into Claude Code, followed by this instruction:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;These mutants survived mutation testing: the suite stays green when any one of these changes is made to calc.py. Write tests in test_calc.py that kill them. Do not modify calc.py or mutate.py. Then run &lt;code&gt;python3 mutate.py calc.py&lt;/code&gt; and keep going until the score is clean.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When I ran exactly that, the agent came back with four tests and a habit I did not ask for: it annotated each one with the wrong answer the mutant would produce. The survivor list had turned into a specification it could compute against.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_discount_applies_at_exactly_ten_items&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # Boundary: quantity == 10 qualifies. 10.0 * 10 = 100, minus 20% = 80.0.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # Kills the &gt; / &gt;=, threshold 10-&gt;11, and every discount-formula mutant:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    #   total / (1 - d/100) -&gt; 125.0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    #   total * (1 + d/100) -&gt; 120.0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;    self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(discounted_total(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;10.0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;10&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;20&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;80.0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_rounds_to_two_decimal_places&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;    # 3.333 must round to 3.33; a round(total, 3) mutant returns 3.333.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;    self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(discounted_total(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;3.333&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;3.33&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two of its four tests are above; the finished suite, those four plus the two you started with, ships as &lt;code&gt;calc_solution_tests.py&lt;/code&gt; in the folder (named without the &lt;code&gt;test_&lt;/code&gt; prefix so it stays out of your way until you want it), so you can run it yourself instead of retyping it from the excerpts. To see the clean score without doing step 3, drop that file in as &lt;code&gt;test_calc.py&lt;/code&gt; and run &lt;code&gt;python3 mutate.py calc.py&lt;/code&gt;. Left as it downloads, the folder still scores 1/10, because the suite the gate runs holds only the two starting tests.&lt;/p&gt;
&lt;p&gt;Then I ran the instrument again myself, because you never take the agent’s word for a score when you can take the score’s word for it:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ python3 mutate.py calc.py&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Score: 10/10 killed, 0 survived.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exit code 0, the instrument’s all-clear. Same code, same tests passing as before, and now green means something it did not mean before: ten classic ways to break this file, and a test rings for every one. (My run went clean in one round, but this toy is small. When yours does not, paste what survived straight back and go again; and for the rare survivor no round can kill, the closing section shows the other move: you let it live, with a comment.)&lt;/p&gt;
&lt;p&gt;One step remains, and it is the one that actually ends the regress: read the four tests it wrote, and check every asserted value against what the code &lt;em&gt;should&lt;/em&gt; do, not against what it does. The distinction is load-bearing. An agent kills mutants by pinning current behaviour; look back at its annotations and you can see the 80.0 was computed from the implementation. On correct code that is exactly what you want. On code that is already wrong, the same move canonises the bug as specification, with a clean mutation score as its alibi.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The score proves the tests ring when the code changes; only your read proves they are ringing for the truth.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;What the instrument buys you is that the read is bounded: four short tests with a known purpose, instead of an unbounded hope about a whole suite.&lt;/p&gt;
&lt;p&gt;Two honest notes before you rely on it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it earns its keep.&lt;/strong&gt; A fresh agent asked cold to “add tests” for that function, with no survivor list, scored 10/10 on its first try. On a four-line function whose docstring names the threshold, the odds are friendly, and the survivor loop buys you little. That is not the point. The point is that on real code you cannot tell whether it guessed well by looking, and the survivor list is what turns “write better tests” from a vague instruction into a measurable work order. The payoff climbs exactly where you cannot eyeball it: a forty-line function, a docstring that has drifted from the code, a module you inherited and half-trust.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;None of this is new, and that is the reassuring part.&lt;/strong&gt; Handing a test-writer a list of surviving mutants is called &lt;em&gt;mutation-guided test generation&lt;/em&gt;, and search-based tools were doing it a decade before anyone had an LLM; the agent is just a better test-writer than they were. Google’s setup is the part that matters for you here: it shows surviving mutants in code review, on the lines a change touches, which its authors argue is where a developer is most likely to act, after they found no actionable way to hand engineers a whole-repository score.&lt;sup&gt;&lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fn-2&quot; id=&quot;user-content-fnref-2&quot; data-footnote-ref=&quot;&quot; aria-describedby=&quot;footnote-label&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; The survivor list works the same way: a short work order on the code you just changed. Meta has published a version of this pattern with an LLM at the test-writing end.&lt;sup&gt;&lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fn-3&quot; id=&quot;user-content-fnref-3&quot; data-footnote-ref=&quot;&quot; aria-describedby=&quot;footnote-label&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; You are running the individual-developer version of a documented industrial practice.&lt;/p&gt;
&lt;h2 id=&quot;when-it-still-goes-wrong&quot;&gt;When it still goes wrong&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is slow on a real repo.&lt;/strong&gt; Every mutant is a full suite run, so keep it out of the Stop hook: the gate fires every turn, this audit runs when the code or tests change. Industrial tools go further, mutating just the lines a change touches, which is how Google runs it at scale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A clean score is a bounded claim.&lt;/strong&gt; 10/10 covers only this tool’s small vocabulary of breaks. Real gaps live outside it: money in binary floats that no swap can expose, and “kills” that are really crashes, not caught assertions. The score narrows the worry; it does not end it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Some survivors cannot be killed.&lt;/strong&gt; An equivalent mutant reads differently but behaves identically, so no test can catch it. If you cannot name an input that would expose a survivor, it may be one, and it is allowed to live with a comment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The score is a worklist.&lt;/strong&gt; Optimise the percentage like a KPI and you get tests engineered to twitch at mutants rather than to state what the code should do, which is the disease “the tests pass” had, one level up. Chase named survivors; ignore the number.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The verdict contradicts the file you are reading.&lt;/strong&gt; You are running a stale compiled copy of the file you mutated. Delete &lt;code&gt;__pycache__&lt;/code&gt; or &lt;code&gt;touch&lt;/code&gt; that file. (mutate.py already gives its own runs a fresh cache.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not on Python?&lt;/strong&gt; The instrument is language-specific; the method is not. Reach for mutmut on Python, PIT on the JVM, Stryker on JavaScript and TypeScript, cargo-mutants on Rust. The rule holds everywhere: break the code, count the catches.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;prove-it-on-code-you-ship&quot;&gt;Prove it on code you ship&lt;/h2&gt;
&lt;p&gt;Pick the smallest file in your own project that has tests you trust. Four moves before you run it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Confirm a green baseline.&lt;/strong&gt; The instrument refuses a red baseline, because against a failing suite every verdict is noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Point &lt;code&gt;TEST_COMMAND&lt;/code&gt; at your test runner.&lt;/strong&gt; It sits at the top of mutate.py, defaulting to plain unittest. On pytest, swap that one line, keeping it a list of arguments, not a string: &lt;code&gt;[sys.executable, &quot;-m&quot;, &quot;pytest&quot;, &quot;-q&quot;, &quot;tests/test_foo.py&quot;]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Narrow it to the file you are mutating.&lt;/strong&gt; Aim at just the tests that exercise that file, not the whole suite. Every mutant runs the command once, so a wide one is the difference between a coffee and an afternoon, and the reason people quit after one slow run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Predict, then compare.&lt;/strong&gt; Write down the score you expect before you look. The gap between the number you predicted and the number you got is the most useful thing this walkthrough produces.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Here is the anatomy of the number I opened with, re-run the morning this published so it is evidence and not a memory. The module: 822 lines of my own automation, 21 green regression tests. The run: 105 mutants, &lt;strong&gt;38 killed, 67 survived&lt;/strong&gt;. Where the 67 hid is the whole lesson. Point coverage at the same suite and the module reads 43 per cent covered; 49 of the survivors sit in code no test executes at all, which a coverage report already flags for you. The other 18 are the ones only mutation can see, because they sit on lines coverage counts as covered.&lt;/p&gt;
&lt;p&gt;One function holds most of them: a weekly counter the suite genuinely imports, calls, and runs green, whose single test asserts that the count is a non-negative integer and checks nothing else. So flip the sign on its seven-day window and it looks a week into the future, the count silently collapses toward zero, and all 21 tests still pass. That is &lt;code&gt;test_zero&lt;/code&gt; from step 1 wearing a production badge: &lt;strong&gt;a fully-covered function that no assertion pins&lt;/strong&gt;, in my own code, caught by the instrument you just ran on a toy.&lt;/p&gt;
&lt;p&gt;So I did step 3 on my own code. I handed that survivor to the agent with the same work order, and it wrote the test the counter never had: stage a single item inside the window, then assert the count is exactly one, not merely non-negative. I applied the sign-flip by hand and ran both assertions.&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;current_weekly_promotion_count() = 0   (one item, aged 1 day, window = 7 days)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  assert count &gt;= 0   -&gt;  PASS   (the assertion that was already there)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  assert count == 1   -&gt;  FAIL   (the assertion the agent just wrote)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The old check stayed green on a function that now counts nothing, exactly as it had all along. The new one went red, because a window flipped a week into the future finds zero where it should find one. One survivor, handed over and killed, on the first test that pinned a number instead of trusting a sign.&lt;/p&gt;
&lt;p&gt;The fix for most of the others is that same move: &lt;strong&gt;pin the number the code should return&lt;/strong&gt;, so a window that flips or a boundary that slips finally has somewhere to fail.&lt;/p&gt;
&lt;p&gt;One survivor on that function is the exception. The window admits an item on &lt;code&gt;mtime &gt;= cutoff&lt;/code&gt;, and to tell &lt;code&gt;&gt;=&lt;/code&gt; from &lt;code&gt;&gt;&lt;/code&gt; you need a file whose modification time lands on the exact cutoff instant. The cutoff is read from the clock, so the only way to hit that instant is to mock the clock, and I judged that test double heavier than the bug it would catch. So it stays alive, with a comment naming why.&lt;/p&gt;
&lt;p&gt;It is worth being precise about what it is, because it is not an equivalent mutant: an equivalent mutant is one no input can expose, and this one has an input, a mocked clock, that I have simply declined to write.&lt;/p&gt;
&lt;p&gt;That is step 3 on the days the loop does not go clean: some survivors &lt;strong&gt;earn a real assertion&lt;/strong&gt;, some &lt;strong&gt;earn a refactor ticket&lt;/strong&gt;, one &lt;strong&gt;earns a comment&lt;/strong&gt; explaining why it lives, and telling which is which is the work. If your score stings, the sting is information, the first honest measurement your suite has ever had.&lt;/p&gt;
&lt;p&gt;Then add this rule to your &lt;code&gt;CLAUDE.md&lt;/code&gt;, the standing instructions your agent reads each session, so the discipline survives you forgetting it:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;markdown&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;When you fix a bug: first write a test that fails on the broken code,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;show me it failing, then fix the code. A test I have never seen fail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;does not count as coverage.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the manual mutant from step 1, promoted to standing policy: every bugfix now arrives with proof that its test can ring.&lt;/p&gt;
&lt;p&gt;You are holding three instruments now, and most arguments about testing are really an argument between two of them. Coverage asks whether a line ran. Mutation asks whether a break in that line would be caught. Your own read asks whether the behaviour a passing test pins is the one you actually wanted.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Coverage measures reach. Mutation measures detection. Only your read decides what is right.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The gate keeps the agent honest about whether the tests pass; mutation and your own read are what keep you honest about whether they are worth passing. Green stops being where the question ends. It moves from &lt;em&gt;are the tests passing&lt;/em&gt; to &lt;em&gt;what broken versions did these tests catch&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id=&quot;if-you-never-write-code&quot;&gt;If you never write code&lt;/h2&gt;
&lt;p&gt;Break the code, count the catches: the method never depended on Python. It works on anything an agent hands back where being wrong is checkable, and the case you most need it for is not code at all. It is the claim.&lt;/p&gt;
&lt;p&gt;Here is the whole thing with no code in it. Take a report or a summary you know cold. Copy it, and plant a few deliberate errors in the copy, the kind of wrong that would cost you something if it slipped through. Hand the copy to your “review this” agent, and count what it catches.&lt;/p&gt;
&lt;p&gt;I ran it on one of my own weekly stats summaries before publishing. In a copy I changed a signup count from four to six, moved a date back by a day, and added a confident sentence that one post had our weakest open rate, which it did not. Then: “check this against the raw export.” It caught all three, and flagged a fourth claim I had written carelessly and never planted.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Three planted, three caught&lt;/strong&gt;, and the review earned my trust the same way step 1’s test did: I had watched it catch mistakes I controlled before I believed the ones I did not. A review you have never watched catch a planted error is worth exactly what a test you have never watched fail is worth.&lt;/p&gt;
&lt;p&gt;The catches are your kills; the misses are the claims you must never hand over unchecked. Code is the easy case, because a machine runs it and a mutant is unambiguous. The claims your agent writes when there is no suite to run, the summary and the analysis and the report, are the harder case, and the one worth an instrument of its own: one that has to find the errors you did not think to plant, checking each claim against its source instead of against a copy you already know is wrong. That is where this goes next.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Related: &lt;a href=&quot;https://durabilitycurve.com/blog/how-reliable-is-your-ai-agent/&quot;&gt;How Reliable Is Your AI Agent&lt;/a&gt; makes the broader case, &lt;a href=&quot;https://durabilitycurve.com/blog/the-stable-liar/&quot;&gt;The Stable Liar&lt;/a&gt; shows the failure mode up close, and &lt;a href=&quot;https://durabilitycurve.com/blog/never-let-claude-code-tell-you-its-done/&quot;&gt;Never Let Claude Code Tell You It’s Done&lt;/a&gt; builds the test gate this piece audits.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;What was your score, and which survivor surprised you? The answers steer what gets built next.&lt;/em&gt;&lt;/p&gt;
&lt;section data-footnotes=&quot;&quot; class=&quot;footnotes&quot;&gt;&lt;h2 class=&quot;sr-only&quot; id=&quot;footnote-label&quot;&gt;Footnotes&lt;/h2&gt;
&lt;ol&gt;
&lt;li id=&quot;user-content-fn-1&quot;&gt;
&lt;p&gt;Richard DeMillo, Richard Lipton and Frederick Sayward, &lt;a href=&quot;https://doi.org/10.1109/C-M.1978.218136&quot;&gt;“Hints on Test Data Selection: Help for the Practicing Programmer,”&lt;/a&gt; &lt;em&gt;IEEE Computer&lt;/em&gt; 11(4), 1978, 34–41. The paper that introduced mutation analysis and, with it, the coupling effect; the idea itself is usually traced to a 1971 class paper of Lipton’s. &lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fnref-1&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 1&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-2&quot;&gt;
&lt;p&gt;Goran Petrović and Marko Ivanković, &lt;a href=&quot;https://doi.org/10.1145/3183519.3183521&quot;&gt;“State of Mutation Testing at Google,”&lt;/a&gt; &lt;em&gt;Proceedings of ICSE-SEIP 2018&lt;/em&gt;. Two findings carry into this piece: mutation analysis is made tractable across Google’s roughly two billion lines by mutating only the lines a change touches; and the authors argue that code review is the best place to surface mutants, since it maximises the chance a change is acted on, after finding no actionable way to surface a whole-repository mutation score. &lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fnref-2&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 2&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&quot;user-content-fn-3&quot;&gt;
&lt;p&gt;Christopher Foster et al., &lt;a href=&quot;https://arxiv.org/abs/2501.12862&quot;&gt;“Mutation-Guided LLM-based Test Generation at Meta,”&lt;/a&gt; arXiv:2501.12862, 2025. Meta’s ACH system plants undetected faults, then has an LLM write the tests that kill them. &lt;a href=&quot;https://durabilitycurve.com/blog/your-tests-pass-so-what/#user-content-fnref-3&quot; data-footnote-backref=&quot;&quot; aria-label=&quot;Back to reference 3&quot; class=&quot;data-footnote-backref&quot;&gt;↩&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;</content:encoded></item><item><title>Never Let Claude Code Tell You It&apos;s Done</title><link>https://durabilitycurve.com/blog/never-let-claude-code-tell-you-its-done/</link><guid isPermaLink="true">https://durabilitycurve.com/blog/never-let-claude-code-tell-you-its-done/</guid><description>A test the agent can&apos;t talk its way past, wired to run itself.</description><pubDate>Mon, 29 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What you will do:&lt;/strong&gt; add one test that catches the agent’s mistakes, then wire it so Claude Code runs it on its own and cannot end a turn while it is failing. About twenty minutes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; you use Claude Code on real code, and it has told you “fixed it, tests pass, done” when it was not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who should skip it:&lt;/strong&gt; if your agent already cannot end on a failing test, you are past this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You need:&lt;/strong&gt; Claude Code (version 2.1.143 or later), Python 3 for the worked example (it uses the built-in test runner, nothing to install), and a project of your own.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Claude Code will tell you it fixed the bug. It will tell you the tests pass. It will tell you it is done. And sometimes it is wrong, and it says all of it with the same calm certainty it uses when it is right. “Done” is the most expensive thing it gets wrong, because the moment you believe it, you stop looking.&lt;/p&gt;
&lt;p data-cover-after=&quot;&quot;&gt;The reason is plain. The model is rewarded for sounding finished, and sounding finished is not the same as being finished. The gap between the two is invisible at exactly the moment you have decided to trust it. You cannot close that gap by asking the agent to be more careful. You close it with a check the agent runs but does not get to grade: its own tests, with a real pass or fail.&lt;/p&gt;
&lt;h2 id=&quot;write-a-test-that-says-what-you-want&quot;&gt;Write a test that says what you want&lt;/h2&gt;
&lt;p&gt;A test is a small program that runs your code and checks it does the right thing. The important word is &lt;em&gt;yours&lt;/em&gt;. The test has to encode what &lt;em&gt;you&lt;/em&gt; want the code to do, because if the agent writes both the code and the test, it can quietly make the two agree. The check has to come from outside the agent, or it is not a check.&lt;/p&gt;
&lt;p&gt;Here is the smallest possible example: a function, and a test for it. The agent was asked to fix &lt;code&gt;add&lt;/code&gt;, and reported back that it was done.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;calc.py&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; add&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(a, b):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; a &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; b&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;test_calc.py&lt;/code&gt;&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; unittest&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;from&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; calc &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;import&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; add&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;class&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; TestAdd&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;unittest&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;TestCase&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_basic&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;        self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(add(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;2&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;5&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;    def&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; test_zero&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;(self):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;        self&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;.assertEqual(add(&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;), &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; __name__&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;__main__&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    unittest.main()&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save both files in the same empty folder and open your terminal there (&lt;code&gt;cd&lt;/code&gt; into it). They have to sit together, because the test does &lt;code&gt;from calc import add&lt;/code&gt;. Now run the tests. (&lt;code&gt;python3 -m unittest&lt;/code&gt; finds and runs every &lt;code&gt;test_*.py&lt;/code&gt; file in the folder. It is built into Python, so there is nothing to install.)&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ python3 -m unittest&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;F.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;======================================================================&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;FAIL: test_basic (test_calc.TestAdd)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;----------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Traceback (most recent call last):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  File &quot;test_calc.py&quot;, line 8, in test_basic&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;    self.assertEqual(add(2, 3), 5)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;AssertionError: -1 != 5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;----------------------------------------------------------------------&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;Ran 2 tests in 0.000s&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;FAILED (failures=1)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The agent was certain. The test does not care how certain it was. &lt;code&gt;add(2, 3)&lt;/code&gt; came back &lt;code&gt;-1&lt;/code&gt;, not &lt;code&gt;5&lt;/code&gt;, and now you know, in one second, that “done” was not true. That is the whole idea: a fact about the code the agent cannot argue with.&lt;/p&gt;
&lt;p&gt;When you write a test for your own code, pick an input where a broken version and a correct one give clearly different answers. If your test passes on code you already know is wrong, the inputs are not separating right from wrong yet, and the gate will wave the bug straight through.&lt;/p&gt;
&lt;h2 id=&quot;put-it-behind-a-gate-the-agent-cannot-talk-past&quot;&gt;Put it behind a gate the agent cannot talk past&lt;/h2&gt;
&lt;p&gt;A test you remember to run is already worth a lot. But you will not always remember, and the agent can finish and hand control back to you before you check. So wrap the test in a gate: a small script that runs it and turns the result into a hard yes or no.&lt;/p&gt;
&lt;p&gt;Save this as &lt;code&gt;check.sh&lt;/code&gt; in your project:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;#!/bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# A Stop-hook gate. Claude Code runs this when the agent tries to end its turn.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# If your tests pass it exits 0 and the agent is free to stop. If any fail it&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# prints them and exits 2, which blocks the stop: the agent is handed the output&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# and has to keep working, so it cannot end the turn while the suite is red.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;${&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;CLAUDE_PROJECT_DIR&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;:-&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;.}&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; exit&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;   # run from the project root, not wherever the agent cd&apos;d to&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# PYTHONPYCACHEPREFIX gives Python a fresh bytecode-cache dir each run, so the gate&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# can never pass on bytecode compiled from older code that was rewritten at the same&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D&quot;&gt;# size and timestamp (a false green this gate exists to prevent). The trap removes it.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;tmpdir&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;$(&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt;mktemp&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -d&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;)&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;trap&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &apos;rm -rf &quot;$tmpdir&quot;&apos;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; EXIT&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;output&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$(PYTHONPYCACHEPREFIX&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$tmpdir&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#B392F0&quot;&gt; python3&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; -m&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; unittest&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; 2&gt;&amp;#x26;1&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; [ &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;$?&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; -eq&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt; ]; &lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt;then&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;    exit&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#F97583&quot;&gt;fi&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;The tests are not passing. Do not stop. Fix these and try again:&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &gt;&amp;#x26;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;echo&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt; &quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$output&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#F97583&quot;&gt; &gt;&amp;#x26;2&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;exit&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt; 2&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not on Python? Replace the &lt;code&gt;python3 -m unittest&lt;/code&gt; line with whatever runs your suite (&lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;go test ./...&lt;/code&gt;, &lt;code&gt;pytest&lt;/code&gt;), anything that exits nonzero when tests fail. That is all the gate needs. The &lt;code&gt;tmpdir&lt;/code&gt;, &lt;code&gt;trap&lt;/code&gt;, and &lt;code&gt;PYTHONPYCACHEPREFIX&lt;/code&gt; lines are a Python-only wrinkle you can drop; keep the &lt;code&gt;cd&lt;/code&gt; so the runner still fires from your project root.&lt;/p&gt;
&lt;p&gt;Run it on the broken code and it answers plainly. (Trimmed here to the lines that matter; you will see the same full traceback as Step 1.)&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ bash check.sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;The tests are not passing. Do not stop. Fix these and try again:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;F.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;FAIL: test_basic (test_calc.TestAdd)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;AssertionError: -1 != 5&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;FAILED (failures=1)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ echo &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;$?&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;2&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;2&lt;/code&gt; is the load-bearing part. An ordinary failure exits &lt;code&gt;1&lt;/code&gt;. We exit &lt;code&gt;2&lt;/code&gt; on purpose, because of what Claude Code does with it next.&lt;/p&gt;
&lt;h2 id=&quot;make-the-gate-run-itself&quot;&gt;Make the gate run itself&lt;/h2&gt;
&lt;p&gt;Claude Code has hooks: scripts it runs for you on certain events (like “a file was edited” or “the agent is about to stop”), without being asked. The one we want is &lt;code&gt;Stop&lt;/code&gt;, which runs the instant the agent tries to end its turn.&lt;/p&gt;
&lt;p&gt;Wire &lt;code&gt;check.sh&lt;/code&gt; to it. In your project, make the &lt;code&gt;.claude&lt;/code&gt; folder if it is not there, then create or open &lt;code&gt;.claude/settings.json&lt;/code&gt; and add:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;  &quot;hooks&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;    &quot;Stop&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;      {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;        &quot;hooks&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;          {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;            &quot;type&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;command&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;            &quot;command&quot;&lt;/span&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;bash &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;\&quot;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;${CLAUDE_PROJECT_DIR}/check.sh&lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;\&quot;&lt;/span&gt;&lt;span style=&quot;color:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;          }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;        ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;      }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;    ]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt; is Claude Code’s own name for your project root, so the hook finds &lt;code&gt;check.sh&lt;/code&gt; wherever the agent has wandered to during the turn (quoted so it survives a path with spaces), and runs it through &lt;code&gt;bash&lt;/code&gt; so you never have to mark the file executable. The &lt;code&gt;cd&lt;/code&gt; at the top of the script does the other half of the job: a hook runs in whatever directory the agent last moved to, not the project root, so the &lt;code&gt;cd&lt;/code&gt; puts the test run back where your tests actually live. If the hook never seems to fire, check that JSON for a typo first: a settings file with a JSON error is rejected whole, so one stray comma takes your hook down with it.&lt;/p&gt;
&lt;p&gt;Here is why the exit code mattered. When a &lt;code&gt;Stop&lt;/code&gt; hook exits &lt;code&gt;2&lt;/code&gt;, Claude Code &lt;strong&gt;blocks the stop&lt;/strong&gt;: it refuses to let the agent finish, feeds your test failures back to it as the reason, and makes it keep working. The agent cannot tell you it is done while the suite is red, because the suite, not the agent, now decides when the turn is allowed to end. (You need Claude Code 2.1.143 or later. Versions move, so the prove-it step below is how you confirm it on your own machine, not my word.)&lt;/p&gt;
&lt;p&gt;When the code is actually fixed, the same gate gets out of the way. Change &lt;code&gt;add&lt;/code&gt; to &lt;code&gt;return a + b&lt;/code&gt;, and:&lt;/p&gt;
&lt;pre class=&quot;astro-code github-dark&quot; style=&quot;background-color:#24292e;color:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;console&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ bash check.sh&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#E1E4E8&quot;&gt;$ echo &lt;/span&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;$?&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Silent, exit &lt;code&gt;0&lt;/code&gt;, the agent is free to stop. Green means go.&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;The Stop-hook gate decides when the turn can end: tests pass means exit 0 and the turn ends; tests fail means exit 2, the failures are fed back, and the agent keeps working until the suite is green.&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot;  width=&quot;3200&quot; height=&quot;1640&quot; src=&quot;https://durabilitycurve.com/_astro/fig-stop-hook-gate-decision-2026-06-29.DFmAEbYQ_Z2v4PBT.webp&quot; &gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The one rule that keeps this honest.&lt;/strong&gt; The gate does not make the agent honest on its own. It makes exactly one thing checkable from outside it: whether your tests pass. That holds only while the check stays out of the agent’s reach. It must never edit the test, the gate, or the &lt;code&gt;.claude/settings.json&lt;/code&gt; hook to slip past them, so keep those out of its edit scope, or read any change to them before you trust a green run. To make that a wall instead of a rule, a &lt;code&gt;PreToolUse&lt;/code&gt; hook can hard-block any edit to those files: the same exit-2 move, aimed one tool call earlier. The day you let it rewrite your own test, you have handed the check back to the thing being checked, and you are back to trusting “done.” If your agent likes to “fix” failing tests, say so in your &lt;code&gt;CLAUDE.md&lt;/code&gt;: change the code, never the test.&lt;/p&gt;
&lt;h2 id=&quot;when-it-still-goes-wrong&quot;&gt;When it still goes wrong&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A gate that always blocks would loop.&lt;/strong&gt; If the agent genuinely cannot fix the tests, a &lt;code&gt;Stop&lt;/code&gt; hook that keeps exiting &lt;code&gt;2&lt;/code&gt; would trap it. Claude Code ends the turn on its own after 8 consecutive blocks (change the limit with the &lt;code&gt;CLAUDE_CODE_STOP_HOOK_BLOCK_CAP&lt;/code&gt; environment variable), but the count resets whenever Claude calls a tool, so an agent that keeps editing between attempts can run past it. For a limit that tool calls do not reset, have &lt;code&gt;check.sh&lt;/code&gt; give up and exit &lt;code&gt;0&lt;/code&gt; after a few tries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A big suite makes every stop slow.&lt;/strong&gt; On a large repo, running the whole suite on each stop attempt drags, and the agent can thrash on small tasks. Point &lt;code&gt;check.sh&lt;/code&gt; at a fast subset (the tests near what you changed) and leave the full run to CI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Green is only as good as the test.&lt;/strong&gt; The gate proves the tests pass, not that the tests are &lt;em&gt;enough&lt;/em&gt;. A test that asserts nothing passes happily. The gate is exactly as honest as what you put in it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It only guards what the tests touch.&lt;/strong&gt; Untested code, prose claims, “I checked the docs”: the gate sees none of that. It catches the lie that matters most, that the work is not actually working, and leaves the rest to you.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;prove-the-gate-fires&quot;&gt;Prove the gate fires&lt;/h2&gt;
&lt;p&gt;Do not take my word, or the agent’s, that the gate works. A gate you have never watched block is not a gate. Break something on purpose: change a line so a test fails, then ask Claude Code to wrap up. Watch it get pulled back and handed the failure instead of stopping. Once you have seen it block, a clean finish from the agent finally means something.&lt;/p&gt;
&lt;p&gt;That is the floor, and it is a real one: the agent can no longer decide for itself that the job is done. Something it cannot argue with does. The next step is widening the gate from “the tests pass” to “the tests are worth passing,” which is the next walkthrough.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;If you want the why under this: &lt;a href=&quot;https://durabilitycurve.com/blog/how-reliable-is-your-ai-agent/&quot;&gt;How Reliable Is Your AI Agent&lt;/a&gt; makes the broader case, and &lt;a href=&quot;https://durabilitycurve.com/blog/the-stable-liar/&quot;&gt;The Stable Liar&lt;/a&gt; is this exact failure mode up close.&lt;/em&gt;&lt;/p&gt;</content:encoded></item></channel></rss>