Chapter 1 of 7 · 10 min read
A non-zero offset is a conflict wearing a success message
On this page
The incident review turned on a single line of agent output that nobody had read at the time:
Applying patch to billing/gateway.py
Hunk #1 succeeded at 402.
Hunk #2 succeeded at 812 (offset 47 lines).
2 hunks applied, 0 failed.The task had been to add jitter to the retry backoff in the card-charging path, because a downstream outage had produced a synchronised retry storm that kept the gateway down for an extra eleven minutes. The agent produced a patch, the applier reported success, billing/gateway.py parsed, the module's 214 tests went green, and a reviewer approved a diff that looked exactly like the ticket. Three days later a smaller outage produced the same synchronised storm, and somebody finally opened the merged commit: the jitter had gone into refund(). The charge path had never been touched. The only evidence that anything had gone wrong was sitting in the applier's own log, wearing the word succeeded.
It is worth being precise about which program printed that line, because everything in this topic happens inside it. The model never touches the file. It emits a description of an edit, and a separate program reads that description, locates the region of the file it refers to, and rewrites the bytes. That program is the applier, and the step it performs is patch application — git apply, the Unix patch command, an apply_patch tool exposed to the agent, or a few hundred lines of homegrown code doing the same job. The formats those descriptions arrive in were the previous topic's subject; what happens when one of them meets a real file is this one's. A conflict is the moment where the description does not fit the file: the lines the patch expects to find are missing, or have moved, or appear in two places. Conflict handling is the applier's policy for that moment, and the policies available to it are to refuse, to guess, or to report back and let the caller decide. Which of the three is implemented decides whether an agent's edits are safe, because the applier stands between a model that cannot check its own work and a repository that people depend on, and because a guess and an exact match are reported through the same channel in the same vocabulary — the word succeeded, above, covers both.
What the offset number is telling you
A patch hunk carries a stated position and a block of context lines. The stated position — 765, in the hunk header the agent emitted — is where the patch author believed the anchor lived. The context lines are how the applier recognises the anchor if it has moved. When a permissive applier cannot match the context at the stated position, it searches outward, a line at a time in both directions, until the context matches somewhere. The offset it reports is the distance between where it looked and where it landed. Fuzz is the same idea applied to the context itself: when no exact match exists anywhere, the applier drops the outermost context lines and tries again with a smaller anchor, and reports how many lines it gave up.
Both numbers are the applier telling you that its input was wrong about the world, and that it repaired the input on your behalf using a heuristic. For a human running patch against a tarball, the heuristic is usually right and the human is watching. For an agent applying its own edit to a file it will not look at again, both of those conditions fail. The heuristic is proximity, and proximity is only a good proxy for intent when the file contains exactly one plausible target.
billing/gateway.py at the time of the incident was 1,412 lines and contained six copy-pasted retry blocks, one per gateway operation, all of them descended from the same original by way of the same twenty seconds of editor work. Abridged, the two that matter looked like this:
def charge(customer_id, amount_cents):
body = {"customer": customer_id, "amount": amount_cents}
path = "/v1/charges"
key = new_idempotency_key()
attempt = 0
while attempt < MAX_ATTEMPTS:
try:
return self._post(path, body, key)
except TransientError:
attempt += 1
time.sleep(BACKOFF_BASE * (2 ** attempt))
continue
raise GatewayUnavailable(path)
def refund(customer_id, amount_cents):
body = {"customer": customer_id, "amount": amount_cents}
path = "/v1/refunds"
key = new_idempotency_key()
attempt = 0
while attempt < MAX_ATTEMPTS:
try:
return self._post(path, body, key)
except TransientError:
attempt += 1
time.sleep(BACKOFF_BASE * (2 ** attempt))
continue
raise GatewayUnavailable(path)The two functions differ in exactly one line, and that line is the assignment to path. Everything the retry loop does is expressed through variables, which is good factoring and is also what makes the blocks indistinguishable to a text matcher. The hunk the agent emitted changed the time.sleep call and carried the conventional three lines of context on each side:
@@ -765,7 +765,7 @@ def charge(customer_id, amount_cents):
try:
return self._post(path, body, key)
except TransientError:
attempt += 1
- time.sleep(BACKOFF_BASE * (2 ** attempt))
+ time.sleep(BACKOFF_BASE * (2 ** attempt) * random.uniform(0.5, 1.5))
continue
raise GatewayUnavailable(path)Every one of those seven lines is byte-identical in charge and in refund. The function header in the @@ line is decoration that the applier does not match on. In the real file charge began at 711 and its retry block sat at 718; refund's sat at 812. The agent had stated 765, which is equidistant from both, so the applier's outward search hit a candidate at distance 47 in each direction on the same iteration and took the one it scanned first. GNU patch scans forward before backward, so the edit went to 812. The offset 47 lines in the log is the exact size of the mistake, printed at the moment it was made.
Two appliers, and the failure each one turns into
Fuzzy matching exists because of how patches used to travel. A patch posted to a mailing list in 1990 was generated against one snapshot of a source tree and applied by a stranger against a slightly different snapshot, possibly with local modifications, possibly reformatted, and an applier that refused on any drift would have refused most of the time. Larry Wall's patch was built to bridge that gap, and the offset and fuzz behaviour is a deliberate accommodation to a world where the person applying the patch is sitting there reading the output and can tell that hunk 2 landing 47 lines away is suspicious. The accommodation survives intact into agent tooling, where both halves of that assumption are gone: the patch is generated seconds ago against a tree the applier can read directly, and nobody is reading the output.
The alternative is an applier that refuses. It matches the context at the stated position with zero fuzz and, if the file does not agree, returns a rejection with the text it expected and the text it found. Nothing lands. The loop reads the rejection, re-reads the file, and emits a new patch against what is actually there.
| Strict applier | Permissive applier | |
|---|---|---|
| On an anchor that has moved | Rejects | Searches outward, applies at the first match |
| On an ambiguous anchor | Rejects (or never gets that far) | Picks by proximity, logs an offset |
| Failure mode it produces | A hunk that does not apply | A hunk that applies in the wrong place |
| When you find out | Immediately, inside the loop | On review, in CI, or in production |
| Who finds out | The agent | A person |
| Cost of one occurrence | One extra iteration | One review cycle to one incident |
| Signal in the log | FAILED at 765 | succeeded at 812 (offset 47) |
| Recovery available | Automatic | None, because nothing reported a problem |
The costs on that table are worth putting numbers on, because the argument for permissive matching is almost always an argument about cost. A rejected hunk in a typical loop costs one more turn: re-read the file region, re-emit the patch, re-apply. On a mid-sized model with a 40 kB file and a 2 kB patch that is roughly 14 seconds of wall clock and about 12,000 input tokens, call it five cents. Rejections are also not evenly distributed — in a run of 60 agent edits against a repository with normal churn, we saw 9 rejections, of which 7 were fixed by the agent on the immediate retry and 2 needed two attempts. That is about 2 minutes and 50 cents of overhead across the whole run.
The misplaced hunk costs differently. It consumed a full review cycle for two engineers when it was found, roughly 90 minutes between them; it left the original retry-storm bug in production for three additional days, through one outage that it extended; and it introduced an unreviewed behaviour change in the refund path, which is a payments-adjacent surface where unreviewed behaviour changes are the thing the review process exists to prevent. One occurrence outweighed the entire rejection budget of the run by two orders of magnitude, and it did so while every dashboard the loop exposed showed green.
The property that decides how often the conversion happens is measurable, and measuring it takes an afternoon. Take every seven-line window in the repository, normalise leading whitespace, hash it, and count how many windows have a hash that occurs more than once. In billing/, 8.4% of windows were non-unique, concentrated in the retry blocks, the serializer definitions and the test setup fixtures; in a hand-written parser package in the same repository the figure was 0.3%. That spread is the risk profile of permissive matching expressed as a number, and it is stable enough to recompute weekly and alarm on. A repository at 8% duplication is one where a fuzzy applier is choosing between multiple plausible targets on a meaningful fraction of its edits, and knowing that before an incident is better than inferring it afterwards.
That asymmetry is the whole argument, and the tempting version of the opposite argument is worth stating plainly because it is not stupid. Fuzzy matching does recover most rejections for free, and in a repository with little duplication it recovers them correctly nearly every time. What it does is convert a loud, cheap, automatically-handled failure into a quiet, expensive, human-handled one, at a conversion rate that depends on a property of your codebase — how many places a given block of text appears — that nobody measures and everybody underestimates. In a repository with heavy copy-paste between service modules and a strong test suite, the strong test suite is not the counterweight it looks like: the suite covers refund, and after the misplacement refund still works, because adding jitter to a backoff is a change no unit test asserts against. Strictness is the right default there, and the rejection rate is a cost you can see and manage.
The fixture that corrupts on demand
The argument is easy to nod along with and easy to forget the next time the rejection rate is annoying, so the useful artefact is a test that fails without the guard. It is small: two near-identical blocks, an anchor that appears twice, a patch aimed between them.
# tests/test_apply_guard.py
FIXTURE = build_module(
functions=["charge", "refund"],
body=RETRY_BLOCK, # identical text in both
pad_lines=700, # push charge's block to 718, refund's to 812
)
def test_permissive_applier_corrupts_the_wrong_function():
patched = permissive_apply(FIXTURE, JITTER_HUNK_AT_765)
assert "random.uniform" in enclosing_source(patched, "refund")
assert "random.uniform" not in enclosing_source(patched, "charge")That assertion is deliberately written the way the bug behaves rather than the way you wish it behaved, so the test documents the failure and fails loudly if a future applier upgrade changes the tie-breaking direction. The guard then goes on the same fixture:
import ast
from dataclasses import dataclass
@dataclass
class Reject:
reason: str
def enclosing_symbol(source: str, line: int) -> str | None:
"""Innermost def/class containing `line`, or None at module level."""
best = None
for node in ast.walk(ast.parse(source)):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
continue
if node.lineno <= line <= (node.end_lineno or node.lineno):
if best is None or node.lineno > best.lineno:
best = node
return best.name if best else None
def guarded_apply(source: str, patch: str, expected_symbol: str):
result = apply_with_fuzz(source, patch, max_fuzz=0)
for hunk in result.hunks:
if hunk.offset != 0:
return Reject(
f"hunk #{hunk.index} matched at {hunk.applied_at}, "
f"stated {hunk.stated_at} (offset {hunk.offset}); "
f"re-read the file and re-anchor"
)
landed_in = enclosing_symbol(result.candidate, hunk.applied_at)
if landed_in != expected_symbol:
return Reject(
f"hunk #{hunk.index} landed in {landed_in!r}, "
f"task named {expected_symbol!r}"
)
return result.candidateTwo checks, and they are not redundant. The offset check catches the case in the fixture. The symbol check catches the case where the stated position happens to be right and the model still aimed at the wrong copy — offset zero, correct arithmetic, wrong function — which is the same corruption arriving through a different door. It needs the task to have named a symbol, which means the plan that produced the patch has to carry more than a file path, and that is a cheap requirement to impose on the layer above.
Run the guard against the fixture and it prints the sentence the original log should have printed:
REJECT billing/gateway.py: hunk #2 matched at 812, stated 765 (offset 47);
re-read the file and re-anchorWhat this does not get you
The guard is about placement, and placement is a narrow claim. It says the text went where the patch aimed and that where it aimed is inside the symbol the task named. It says nothing about whether the change is correct, whether the file still compiles, or whether the anchor the model wrote was the right anchor in the first place — a uniquely-matching context line describing the wrong function will apply at offset zero, pass the symbol check if the task named that wrong function, and be exactly as wrong as the fixture.
It is also not a merge-conflict mechanism. Three-way merge resolves two divergent edits against a common ancestor, and it has a vocabulary for expressing that both sides changed the same region. A patch applier has no ancestor and no second side; it has a string and a file, so its only honest verdicts are matched and did not match, and everything between those two is the applier guessing. Formats built for agents lean on this: the V4A format documented in Apply Patch | OpenAI API carries no line numbers at all and requires context lines to be unique within the file, escalating to more context when they are not, which is the same conclusion reached by removing the coin flip rather than by detecting it afterwards.
The larger gap is that offset zero and fuzz zero are claims about the file as it exists at the instant of matching. The agent read that file several turns earlier, and a byte-exact anchor will match happily in a file that has changed underneath it in ways that make the edit meaningless. The next chapter is about the window between the read and the apply, and about what has to be captured during the read for a clean match to mean anything at all.
Patch application and conflict handling — read the current version at https://daviddominguez.dev/learn/en/patch-application-and-conflict-handling/a-non-zero-offset-is-a-conflict-wearing-a-success-message