← The ADLC library
Failure modes · 3

Silent scope drift in agent-authored work

The ticket was a two-line config change. The merged diff touched nineteen files, and every one of those changes was individually justified. That is what makes it hard to catch.

The ticket was about as small as tickets get: add a feature flag around the new onboarding email so support can turn it off per tenant. Two lines of config, one conditional, maybe a test. Half an hour of work for someone who knows the codebase.

The merged pull request touched nineteen files.

Walk the diff and you cannot find the place where it went wrong. Adding the flag required a lookup on tenant settings. The tenant settings accessor was inconsistent across the codebase, so it got normalised. Normalising it revealed two call sites that were passing the wrong type, so those got fixed. Fixing those required a small change to the settings schema. The schema change needed a migration. The migration made an existing test fail, because that test had been relying on the old schema’s default, so the test got updated. Updating the test revealed the default had been wrong for eight months, so the default got corrected. Correcting the default changed behaviour for eleven tenants who had never explicitly set that value.

Every single step in that chain is a defensible engineering decision. Several of them are improvements. If you had asked a good engineer about any one of them in isolation, they would have said yes, do that.

The onboarding email flag shipped. So did a behavioural change to eleven tenants that nobody requested, nobody reviewed as a behavioural change, and nobody told support about. Support found out from a customer.

Scope creep is old, this is not scope creep

Teams have always had scope creep and everyone knows how to talk about it. It usually looks like ambition: someone starts a task, sees an opportunity, and expands the work because they want to. It has an author with an intent, and it announces itself. “While I was in there I also…” is one of the most familiar sentences in engineering. It shows up in standups. It gets pushed back on. The process handles it, imperfectly, but it handles it.

What happens with agent-authored work is different in three ways that matter.

It has no intent behind it. Nobody decided to expand the scope. Each expansion was a locally correct response to an obstacle encountered while doing the assigned work. There is no ambition to push back on, no moment where someone chose to do more. The scope grew by following the code, which is exactly what you would want a competent implementer to do, right up until it is not.

It does not announce itself. Human scope creep produces a person who mentions it, because humans want credit for extra work. Agentic scope growth produces a larger diff and a description that faithfully explains all of it, in the same even tone as the small version would have had. Read the description and it sounds like the change was always meant to be this size.

It happens inside one unit of time. The old version of this unfolded over days, which meant a standup or a check-in could catch it mid-flight. The new version happens between coffee and lunch, so the first moment anyone can intervene is at review, by which point the work exists and reverting it feels wasteful. Sunk cost is a much stronger force than people admit, and “well, the normalisation is genuinely better” is a very easy thing to say when the normalisation is sitting right there, finished, with passing tests.

Classic scope creep

  • Driven by ambition
  • Someone decided to expand it
  • Announces itself, people want credit
  • Unfolds over days
  • A standup can catch it mid-flight

Agentic scope growth

  • No intent behind it at all
  • Each step a local response to an obstacle
  • Shows up only as a larger diff
  • Happens between coffee and lunch
  • Nothing exists to catch it in flight
Every individual step is a defensible engineering decision, several are improvements, and the sum is a behavioural change nobody requested.

Why the diff size is the wrong alarm

The obvious instinct is to gate on size. Flag pull requests over some line count. Require a smaller ticket to be split.

I think this mostly fails, for two reasons.

First, line count is a terrible proxy for risk. A 900-line change that renames a symbol across a codebase is safer than an 8-line change to an authorisation check. Teams that gate on size train themselves to split changes into pieces that pass the gate, which frequently means splitting a coherent change into three incoherent ones, each of which is individually unreviewable because it does not make sense on its own. You have not reduced risk, you have hidden it across pull request boundaries, which is strictly worse because now no single review sees the whole thing.

Second, the risky part of the nineteen-file diff was not large. The dangerous change was one line: a default value. It was surrounded by eighteen files of genuinely fine work, and that surrounding was the camouflage. A size gate would have caught this pull request and told the team to split it, and the most likely split puts the default change in a small “schema tidy-up” pull request that gets approved in ninety seconds because it is small.

What you actually want to detect is not size. It is distance from the stated intent. The ticket said “feature flag for onboarding email.” A change to tenant settings defaults is a long way from that, regardless of how many lines it is. A change to the email template is close to it, even if it is enormous.

That framing is more useful and considerably harder to implement, which is why most teams reach for the line count instead.

The category the diff crossed

Here is the version of this heuristic I have found most portable, and it does not need any tooling: watch for changes that cross a category boundary the ticket did not mention.

Categories being things like: data shape, default behaviour, permissions, external interfaces, anything a customer can observe, anything another team consumes, anything with a migration attached, anything that changes what happens when a value is absent.

The onboarding-flag ticket authorised a change in one category: conditional execution of an email. The merged work crossed into schema, defaults, and observable tenant behaviour. Three unauthorised category crossings, one of which had customer impact.

You do not need to forbid crossings. Most of them are fine and some are necessary. What you need is for the crossing to be surfaced as a distinct thing rather than absorbed into the general body of the change. A pull request that says “note: this also changes the default for welcome_email_enabled from false to true, which affects tenants who have never set it” is a completely different review than the same diff without that sentence. The code is identical. The attention it receives is not.

This is one of the few places where I think a small amount of automation pays for itself immediately, because “did this diff touch a migration, a default, a permission check, or a public interface” is mechanically answerable. It does not require judgement. It requires only that someone has written down which paths and patterns fall in which category, once.

Data shapeSchema, migrations, anything that changes what is stored.
Default behaviourThe one-line change that alters what happens for everyone who did not opt in.
PermissionsAuthorisation checks, role logic, anything that decides who may act.
External interfacesPublic APIs, events, anything another team consumes.
Observable behaviourAnything a customer can see happening differently than before.
Not size. Distance from stated intent. The onboarding-flag ticket authorised one category and the merged work crossed three, none of which the diff size would have flagged.

The slow-motion version: drift across pull requests

Everything above describes drift inside one change. There is a nastier variant that happens across several.

A ticket gets picked up. The agent implements it. Something is slightly off, so a follow-up pull request adjusts it. A test is flaky as a result, so another one stabilises the test. A related edge case appears, so a fourth handles it. Four pull requests, all merged, all small, all linked to the same ticket or to none at all.

Nobody ever reviewed the sum. Each individual review was reasonable in isolation, and the cumulative behaviour change is something no single reviewer ever saw. This is the pull request equivalent of a conversation where every sentence is true and the overall impression is false.

It is worse than the single-diff case because there is no artefact that represents the whole. You can at least open a nineteen-file pull request and read it. You cannot open the sum of four pull requests spread over three days unless something is deliberately assembling that view, and by default nothing is. This is one of the things a ground-truth layer is genuinely useful for: assembling the total diff attributable to a ticket, across every branch and pull request that touched it, so that “what did we actually change for this ticket” has an answer that is not a person’s memory.

But note the ordering. The tool assembles the view. Somebody still has to look at it and have an opinion, and if the team’s answer to “does this match what we asked for” is a shrug, the assembled view changes nothing.

Why this gets worse with better agents

A slightly uncomfortable observation: this failure mode is not caused by agents being bad. It is caused by agents being good, and it gets worse as they get better.

A weak implementer hits the inconsistent tenant settings accessor and works around it, because normalising it is beyond what they can safely do. The workaround is ugly. The ugliness is visible in review. Someone says “why is this so awkward” and the real problem surfaces as a conversation rather than as a silent nineteen-file change.

A strong implementer fixes it properly. That is better engineering. It is also how the scope grew.

So there is no version of this where you tune your way out by improving capability. Capability increases the reach of any given task, which increases the average distance between what was asked and what was done. The control has to come from somewhere else: from the specification of boundaries, from category detection, from review that is oriented around intent rather than around correctness. Hoping the model will stay in its lane is hoping for a worse model.

A weak implementer hits the inconsistent accessor and works around it. The workaround is ugly, the ugliness is visible in review, and the scope stays small.

A strong implementer fixes it properly. That is better engineering, and it is how the scope grew. Capability increases the reach of any given task, so this failure mode gets worse as agents get better, not better.

Where this breaks down

Three honest limits, and the first one is substantial.

Constraining scope preserves rot. The nineteen-file diff contained real improvements. The tenant settings accessor was genuinely inconsistent, the two call sites were genuinely wrong, and the default had genuinely been incorrect for eight months. A regime that strictly forbids drift means that inconsistency stays in the codebase, possibly forever, because nobody will ever get a ticket that says “normalise the tenant settings accessor.” Opportunistic improvement is how codebases avoid decay, and it has always depended on people fixing things while they were nearby. If you make that expensive, you will get cleaner pull requests and a slowly worsening codebase, and the second effect will not appear on any dashboard for two years.

The reconciliation I would argue for is: separate the improvements out and let them through fast, rather than blocking them. The problem was never that the accessor got normalised, it was that the default change rode along invisibly. But be honest that “split it out” costs something and some teams will interpret any friction as a signal to stop bothering.

Category detection produces noise. Any mechanical rule that flags migrations, defaults, and interfaces will fire constantly on a healthy codebase, because healthy codebases change those things routinely. A flag that fires on 40 percent of pull requests is not a signal, it is a background hum, and it will be ignored within a fortnight. If you cannot tune it to fire rarely, do not deploy it. An alert nobody reads is worse than no alert, because it creates a false sense that the risk is covered.

And the small-team case again. On a team of four working in one codebase they all know, a nineteen-file drift is caught at review by someone who simply knows that the default was there for a reason. Institutional memory does this job well and cheaply, right up until the team is big enough or new enough that the memory is gone. Scope drift is a problem of context distance more than it is a problem of agents, and if your context distance is short you may be paying for a cure you do not need.

The takeaway

Agent-authored scope drift is not ambition and not carelessness. It is the accumulation of locally correct decisions made while following the code, compressed into a single window of time, arriving with a description that makes the whole thing sound intended.

You will not catch it with size limits, because size is not where the risk lives. You catch it by asking whether the change crossed a category the ticket never mentioned, by surfacing those crossings explicitly rather than letting them be absorbed, and by making sure something somewhere can assemble the total change attributable to a piece of work rather than reviewing it one fragment at a time.

The next piece takes the sum of all of this and looks at what it does to the artefact everyone still makes decisions from: how a board becomes fiction in weeks rather than quarters, and why the drift is so much faster than the one teams learned to live with.