Chapter 6 of 7 · 8 min read
An authorised test change must fail against the old code
On this page
LEDG-2390 asked for the monthly revenue report to exclude cancelled orders, which the finance team had been correcting by hand for two quarters. The agent's first patch changed eleven lines in ledger/reports/monthly.py and four lines across two test files, and the apply step rejected it, because tests/reports/test_monthly.py is read-only. The rejection was correct by the policy shipped in the previous chapter. It was also the beginning of a worse patch.
Given the rejection as feedback, the second attempt kept the tests untouched and added a keyword argument:
def monthly_report(period, *, exclude_cancelled=False):
orders = Order.objects.for_period(period)
if exclude_cancelled:
orders = orders.exclude(status=Order.CANCELLED)
return _aggregate(orders)The suite went green in 36 seconds, because test_monthly_report_includes_all_orders and test_report_totals_match_order_sum both call monthly_report(period) and the default preserves the behaviour they assert. No test was modified, the acceptance record from the previous chapter was spotless — zero test paths, zero assertions changed, diff coverage 0.81 — and the report still includes cancelled orders in production, because nothing in the codebase passes exclude_cancelled=True. The policy did not prevent the failure; it selected for a version of it that its own checks could not see.
The two things a test edit can mean
Every diff that touches an assertion is one of two events, and they look identical in a code review. The assertion was describing behaviour that the ticket has now deliberately changed, in which case editing it is the work, or the assertion was describing behaviour the patch failed to produce, in which case editing it is the evasion. A blanket ban treats both as the second kind. It stops roughly a fifth of the agent's legitimate output on this repository and pushes the rest into shapes like the exclude_cancelled flag, where the requirement is technically implemented, all assertions are satisfied, and nothing has changed.
There is also a quieter cost, which is that the human eventually makes the test edit themselves. The reviewer reads the ticket, sees that the two assertions are now wrong, edits them in the merge commit, and merges. That is the right change made by the right person, with no record connecting it to a decision, no check that it was necessary, and no evidence anywhere that the new assertion describes the requested behaviour rather than the behaviour that happened to arrive. The ban did not eliminate the risk; it relocated it to a place with no instrumentation.
What separates the two events is not who made the edit or how large it was. It is whether the new assertion is a statement about the requested change, and there is a mechanical test for that: a new assertion that encodes a behavioural change must fail when run against the code as it was before the patch. If it passes against the old code, it is not describing anything new, which means it was written to fit something rather than to require it.
Four runs, and what each one settles
The check needs the old source and the new source, the old tests and the new tests, and it runs three or four combinations. Each cell answers a different question:
| Source | Tests | Expected | What it establishes |
|---|---|---|---|
| Old | Old | Green | The baseline is sound and the environment is sane; a red here invalidates everything else |
| Old | New | Red, on exactly the proposed assertions | The new assertions describe a real change; a green here is the accommodation signature |
| New | New | Green | The patch produces the behaviour the new assertions require |
| New | Old | Red on exactly the assertions the proposal claims to invalidate | The behavioural change has no collateral beyond what was declared |
The second row is the one the chapter is named for and the fourth row is the one people leave out. Running the old tests against the new source tells you the true blast radius of the behaviour change, meaning every assertion anywhere in the repository that the change also invalidates. On LEDG-2390 the proposal declared two assertions invalidated; the run of the old suite against the new source failed three, the third being tests/exports/test_finance_feed.py::test_feed_totals_match_report, which compares the export feed to the report and had been passing because both included cancelled orders. That third failure is a genuine finding about the ticket — the finance feed needs a decision too — and it surfaced because the proposal's claim about scope was checked rather than accepted.
The pattern is the same one SWE-bench encodes as FAIL_TO_PASS and PASS_TO_PASS sets per task, and the reason it is the standard there is exactly the reason it works here: it is the only cheap way to distinguish a test that demands something from a test that permits something.
Running the suite four times is not free — on the impacted subset it is roughly 160 seconds instead of 40, and only for the small share of patches that propose a test change. That cost falls entirely on the patches where the evidence is worth the most.
The proposal as its own artefact
The test diff should arrive separately from the source patch and carry its own justification, because it is a different kind of claim and needs a different reader:
{
"task": "LEDG-2390",
"kind": "behaviour_change",
"cites": "Ticket: 'the monthly revenue report should exclude cancelled orders'",
"assertions_modified": [
{"test": "tests/reports/test_monthly.py::test_monthly_report_includes_all_orders",
"was": "assert report.order_count == 412",
"now": "assert report.order_count == 389",
"fails_against_base": true},
{"test": "tests/reports/test_monthly.py::test_report_totals_match_order_sum",
"was": "assert report.total == orders.aggregate_total()",
"now": "assert report.total == orders.exclude_cancelled().aggregate_total()",
"fails_against_base": true}
],
"assertions_removed": [],
"undeclared_failures_against_base_tests": [
"tests/exports/test_finance_feed.py::test_feed_totals_match_report"
]
}Where the authorisation comes from is a scheduling question as much as a policy one. Blocking the loop on a human approval mid-run turns a four-minute task into a next-morning task, so this team grants it two ways: the ticket may pre-authorise specific test paths when whoever wrote it already knows the behaviour is changing, in which case the loop runs uninterrupted and the artefact goes to review with the patch; otherwise the agent finishes the source patch, emits the proposal, and the task parks in a queue rather than consuming attempts. Parked tasks are picked up in a twice-daily sweep, and the median wait is about four hours against a median approval time of eleven minutes.
A reviewer reading that spends their attention on the one question they are uniquely able to answer, which is whether excluding cancelled orders from the report is what the business wanted, including for the finance feed. They are not asked to work out whether the numbers 412 and 389 are consistent with the code, because the four runs established that.
Sorting the fifty-four
The audit from the previous chapter flagged 54 test-touching patches out of 300 accepted, and the argument that this is normal and expected is largely right — it is the conclusion drawn from it that fails. Sorted mechanically, using assertion-level AST comparison and the base-run result, the 54 come apart:
| Category | Count | Mechanical signature |
|---|---|---|
| New tests added, nothing modified | 21 | assertions_added > 0, modified and removed both zero |
| Authorised behaviour change | 12 | Assertions modified, all fail against base, ticket cites a behaviour change |
| Mechanical churn (renames, imports, formatting) | 7 | Zero assertion nodes differ after AST normalisation |
| Corrected a wrong test | 3 | Assertion modified, fails against base and against the patch's own source until the source is fixed |
| Accommodation | 11 | Assertion loosened, deleted or skipped, and the new assertion passes against base |
Forty-three of the 54 pass the gate automatically and take on no additional review burden at all. Eleven do not, and those eleven include the five-cent rounding tolerance. The teammate's objection is answered by the numbers: the policy being proposed is not a ban on test edits, it is a requirement that a test edit prove it demands something, and four fifths of real test edits prove it without a human being involved.
The 21 additions deserve a caveat, because the falsification requirement does not apply to them and must not be forced to. A test written to cover existing, correct, previously untested behaviour passes against base by construction, and that is what a characterisation test is for, which records what the code currently does so that any later change to it has to declare itself. The rule therefore distinguishes added assertions from modified and removed ones: additions are allowed freely and recorded as coverage work, while modifications and removals require the base run to be red. What additions cannot do is count as evidence that the ticket was implemented, so a patch whose only test change is an addition is graded by the assertions that already existed.
What the rule costs when it is wrong
Shipped as an acceptance rule, it reads: a patch may modify or remove an assertion only when accompanied by a proposal artefact whose modified assertions all fail against the base commit, whose declared blast radius matches the old-tests-against-new-source run, and which a named human approves; additions are unrestricted; anything else is rejected at apply time with the reason as feedback.
When it is wrong in the strict direction it costs a review. A test that is genuinely over-specified — asserting on dictionary key order, say — will fail against base for reasons that have nothing to do with the ticket, and it routes to a human who spends about fifteen minutes and approves it. On this repository that happened four times in a quarter.
When it is wrong in the permissive direction it costs a merged accommodation, and it is worth being clear that this remains possible. An agent can write a new assertion that fails against base for a trivial reason while still being weaker than the one it replaced — changing an expected total from 19.99 to 19.97 fails against base, cites the ticket, and enshrines the bug. The rule catches assertions that require nothing; it does not adjudicate whether what they require is right. That judgement is what the human approval step is for, and the artefact exists to put the ticket text and the before-and-after assertion in front of them on one screen.
None of this yet says how well the whole arrangement is working. The loop has a feedback format, a selection owner, a subset with a measured miss rate, and a verifier that cannot be quietly edited, and it reports that agent patches pass on 96% of tasks. Turning that number into a statement anybody should act on requires knowing what it was measured against.
Test execution as the verifier — read the current version at https://daviddominguez.dev/learn/en/test-execution-as-the-verifier/an-authorised-test-change-must-fail-against-the-old-code