Why legacy is the hard case
On a fresh codebase you can point an agent at the work and mostly get away with it. That permission does not transfer, and the reason is your users.
The questions this answers
- §1 Is an agent safe to point at a legacy app with real users?
- §1 When is the honest answer "not yet"?
- §2 My codebase has no tests at all. Where do I start?
- §2 How do I test code I do not understand yet?
- §5 There are no boundaries in here. What do I give the agent to hold?
- §6 I cannot pause delivery for a month. Can I do this while still shipping?
- §8 How much do I have to finish before any of it pays off?
- §10 How do I know the agent did not break something the tests do not cover?
What real users cost you
Four constraints separate this from every other agent story. Each one removes a move that greenfield advice takes for granted.
You do not know what the current behavior is. Nobody designed some of it, and someone depends on it anyway. It might be a null a downstream report parses, or an order of side effects a webhook consumer notices. Nobody wrote any of it down.
You cannot pause. The system serves people during the whole cleanup, so every step has to be shippable on its own. You also cannot rewrite, because the rewrite competes with the running system and loses.
There are no seams. Greenfield advice assumes boundaries exist. Here, most of the labor is building a place to stand before you can move anything.
And the code was never written to be read. Not by an agent, and often not by a person. So the agent's difficulty is a symptom of the codebase rather than a limit of the tool. That distinction matters, and §1 is built on it.
The failure, in order
- at review The diff reads better than the code it replaces. A cleaner diff is not a preserved one, and that gap is where the risk lives.
- at merge Nothing fails. Nothing was checking.
- three weeks on A consumer you did not know about notices. You now debug a refactor you already forgot.
- with §2 done The suite goes red before the commit lands. You lose ten minutes instead of an afternoon.
What this page does not do
- It does not help you understand a system you have not read. Comprehension is separate work, and Cold Codebase is the exercise for it.
- It does not plan a rewrite. Every step below keeps the application running.
- It does not prove the agent's change is correct. §10 is about the size of that gap.
- It does not name your tools. §3 has the mapping across stacks.
- It does not apply to greenfield work, where most of this is optional.
The counter-argument
Skipping the floor is rational on a short horizon, because the work ships nothing and no user benefits from a pre-commit hook. An agent can produce a working feature today on a codebase with zero tests, and the first few will look fine.
They look fine because you are reading every line. You are the check. That holds until the volume rises, and volume is the reason you wanted the agent.
You pay for the floor now, in tedious work that ships nothing, or later, in production bugs and debugging time. I believe the second bill is much larger, because I watched one codebase get cheaper to change as the checks accumulated. I have not measured the ratio and I will not quote one.
At least this tall
Readiness decides the sign of the result, so measure it before you start. Everything in this section is checkable this afternoon.
The amplification thesis
An agent multiplies the discipline it finds. I have argued this at length in the talk, and it is the reason readiness is not a warm-up for the real work. Readiness is the variable.
If your code doesn't meet the bar, AI will make it worse.
The agent doesn't raise your standards, it enforces them. The lower standards you have, the more enforcement you get.
Here is what that means mechanically. The agent reads your code far more than it reads your documentation. It copies the pattern it sees most often. So a low bar does not stay a local problem. The agent applies that bar consistently, across every file it touches, at the speed it works.
Legacy code is where this bites hardest, because the bar is usually unstated. Three spellings of the same role check. Two ways to authorize a request. The agent picks one, then another, then another, and you get a polite version of the worst code in the repository smeared across every new feature.
Code must be "at least this tall" to get on the AI agent ride. Here is the bar, in literal terms.
The seven signals
- tests, local Characterization tests cover the behavior in the area you intend to change. Global coverage is not the question. The target area is.
- one command One command runs the suite, on any machine, with no setup steps and no tribal knowledge.
- real engine Tests run against the database, cache, and queue you run in production.
- one check One command runs the formatter, the linter, and the type checker together.
- a loud build A failing check blocks the merge. If nothing can go red, nothing is checked.
- named units You can name the unit you intend to change, its inputs, and its outputs. If you cannot, start at §5.
- small batches Changes land one decision at a time, so a single revert undoes a single decision.
What to look at: the three exits on the left. Working down, the gate asks whether tests pin the behavior of the area you intend to change, and a no sends you to §2, which is days of work. A yes moves to the next gate: do you have checks that can fail the build? A no there sends you to §3 and §4, which is an afternoon. The last gate asks whether you can name the unit you are changing and its boundary, and a no sends you to §5, about a day per seam. Only a yes at all three reaches the final box, which is handing the change over in §8. Three of the four outcomes are "not yet", and that is the honest shape of the check.
What disqualifies you, for now
Four conditions mean the answer is "not yet". Keep the agent in-the-loop, on small supervised edits, until you fix them.
- The target area has no tests. The agent has nothing to fail against, and neither do you.
- Tests run on a substitute engine. My first suite ran on SQLite while production ran MySQL. I switched the suite to the real engine and several tests broke. Those were the tests that had been lying to me.
- No command can fail. A style guide in prose is a suggestion, and an agent treats it as one.
- Authorization checks sit inline, scattered. Every new file is then a fresh chance to get one wrong. §5 has the fix and the incident that taught me to want it.
What the floor costs
Say the price before the payoff. A formatter and a linter take an afternoon, and characterization tests on the highest-risk paths took days rather than hours, even with the agent drafting them. The six seam extractions in §5 took about a day each, and about two weeks in total.
That is real time on a system that already works, and nobody will thank you for it. Spend it anyway. The cost is fixed. You only choose when it lands.
Score the seven signals against the area you intend to change, not the whole repository. If four or more are missing there, fix the floor before you widen the agent's remit. Days of unglamorous work beats a month of debugging a refactor nobody was checking.
Pin the behavior you cannot lose
Write tests that record what the code does today, including the parts you would rather fix. This is the step everything else rests on.
The general idea, and its origin
Michael Feathers named the characterization test in Working Effectively with Legacy Code in 2004. His definition of legacy code is code without tests. His method is to find a seam, get the code under test, and only then change it.
A characterization test records what the code does. It does not assert what the code should do. That distinction is the whole discipline, and it is what separates this from greenfield testing. On a new project you write a test for intended behavior. Here you write one for actual behavior, because actual behavior is what your users are standing on.
The procedure
- List the code you intend to change. Only that code.
- Rank it by damage. Money, permissions, and customer data go first.
- For each entry, call it the way production calls it. Use the public entry point.
- Assert what it returns today. Record wrong-looking behavior as wrong-looking behavior.
- Run the suite against the engine you run in production.
- If a test fails on its first run, your assumption was wrong. Keep the code. Fix the test.
test "missing organization returns an empty list":
# Recorded from production behavior, 2026-08-31.
# A user with no organization gets 200 and an empty list.
# This looks wrong. It is also what four callers depend on.
login(admin)
response = GET "/orders?organization=999"
assert response.status == 200
assert response.body == { orders: [], total: 0 }
What to look at: the comment and the second assertion. It pins a response that looks like a bug, so an agent that "cleans this up" into a 404 turns the suite red instead of shipping. Delete the pin later, on purpose, and fix the callers in the same change.
Testing code you do not understand yet
You do not need to understand the code to pin it. Call it, print what comes back, and assert that. Feathers' term for the technique is sensing: you are recording an observation, not stating a requirement.
The agent is genuinely good at this part, and it is the first place I let mine work. I pointed it at a controller, described the endpoint, and had it draft the assertions. I reviewed and corrected every one. That turned roughly a week of manual work into a couple of days, on the most tedious task in the whole project.
Make the setup hard to misuse
Test setup is where an agent guesses. Give it one obvious way to build a user, a tenant, or an order. A fluent factory with named states beats ten lines of role assignment in every test, and the agent will copy whichever it finds.
In the Laravel codebase that meant a test factory reached through one facade, so a test read
UserFactory::admin()->create() rather than a paragraph of setup. Any stack has
an equivalent: FactoryBot traits, factory_boy subfactories, a builder function.
Design your test setup for the dumbest correct user, because that is how an agent uses it.
How you know it worked
- run it It passes on unchanged code. If it fails, you recorded an assumption rather than the behavior.
- break it Change one line in the code under test. The suite must go red. A test that stays green is decoration.
- hand it over Ask the agent for the refactor. The suite is now the answer to "did behavior change".
When it does not work
Two failures are common. Your test asserts an implementation detail rather than a behavior, so it goes red on every refactor including the correct ones, and the team learns to ignore it. Rewrite it against the public entry point.
Or your test passes on a substitute engine and fails on the real one. Foreign keys, date functions, and full-text search all differ between engines. Run the production engine in a container from the first test onward.
The suite is the specification, and it is the only artifact in the repository that an agent cannot argue with. Start with the module you most want the agent to change. That single step changes what you can safely hand over.
Put every standard behind one command
Tests say whether the code works. They say nothing about whether it is consistent. Add a formatter, a linter, and a type checker, and give the agent one command that runs all three.
Why prose does not work
"Follow our coding conventions" is a suggestion. An agent may follow it, and you will spend review time on formatting instead of logic. A command that exits non-zero is a fact. The agent runs it, reads the failure, fixes the code, and runs it again. No judgment is involved, so no human is needed.
Each tool removes a category of wrong output. Without any, the agent can be wrong in an unbounded number of ways. With all three, the remaining space is much smaller, and what is left is mostly the judgment calls you wanted to keep.
Three tools, three jobs
- formatter Removes style from review entirely. The diff then contains only decisions.
- linter Catches structural rot: unused code, missing hook dependencies, unsafe patterns your team has banned.
- type checker Catches a class of logic error before any test runs. On legacy code, expect a large first report.
# The agent never learns your flags. It learns two verbs.
lint: format-check lint-code typecheck
format-check:
docker compose exec app $(FORMATTER) --check
lint-code:
docker compose exec app $(LINTER)
typecheck:
docker compose exec app $(TYPECHECKER)
test:
docker compose exec app $(TEST_RUNNER)
What to look at: the target names, not the tools. Every project answers to
make lint and make test regardless of stack. Running inside the
container is what makes a local pass and a CI pass mean the same thing.
The same step in eight stacks
| Stack | Test runner | Real engine | Format and lint | Types |
|---|---|---|---|---|
| Laravel (PHP) | PHPUnit / Pest | MySQL in Docker | Pint · Psalm | Psalm |
| Rails (Ruby) | RSpec / Minitest | Postgres in Docker | RuboCop | Sorbet / Steep |
| Django (Python) | pytest-django | Postgres in Docker | Ruff · Black | mypy / pyright |
| Next.js (TS) | Vitest / Jest | Testcontainers | Prettier · ESLint | tsc --noEmit |
| Spring Boot (Java) | JUnit 5 | Testcontainers | Spotless · Checkstyle | javac |
| ASP.NET (C#) | xUnit / NUnit | Testcontainers | dotnet format · Roslyn | nullable refs |
| Go | testing + testify | dockertest | gofmt · golangci-lint | the compiler |
| Phoenix (Elixir) | ExUnit | Postgres sandbox | mix format · Credo | Dialyxir |
The tools in that table will change. The four questions will not, because they are the four categories of error an agent can produce on any stack.
How you know it worked
- The agent runs the check without being asked, because §7 tells it to.
- A formatting failure never reaches your review again.
- Each failure message names the command that fixes it. "Run
make format-fix" beats "formatting error on line 47", and the agent acts on the first one.
When it does not work
A slow check gets skipped. Keep it under a minute, or split it: a fast subset for the hook, the full run in CI. A linter reporting 400 pre-existing violations gets ignored, which is the normal opening position on legacy code. Baseline the existing violations, fail the build on new ones only, and burn the baseline down as you touch files.
Put the gate where it cannot be skipped
Two gates, different jobs. A hook catches most problems locally and in the same session. CI is the one nobody can bypass.
The hook runs before a commit lands, so the agent sees the failure while it is still working and fixes it without you. Anyone can skip a hook, by accident or with a flag, so treat it as a convenience rather than a guarantee.
CI is the guarantee. It runs the full suite on every push, in containers built from the same definition as local. If it fails, the pull request does not merge. The pipeline is identical for human-written code and agent-written code, with no exceptions for either.
.git/hooks/pre-commit, POSIX shell
watched
#!/bin/sh
# Fast checks only. Keep this under 30 seconds or it gets skipped.
set -e
if ! make format-check; then
echo "Formatting failed. Run: make format-fix" >&2
exit 1
fi
if ! make lint-code; then
echo "Lint failed. Fix each finding above, then commit again." >&2
exit 1
fi
What to look at: the two echo lines. The agent reads standard error and acts
on it, so a message naming the fix command closes the loop without a human. A bare exit
code does not.
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make up # the same containers as local
- run: make lint # formatter, linter, type checker
- run: make test # the characterization suite
- run: make test-js # the frontend suite
What to look at: no step here is CI-only. Because the pipeline runs the commands you run locally, nothing passes on your machine and fails in the pipeline for a reason you cannot reproduce.
What to look at: every edge that runs backward. Pinned behavior reaches the agent, which changes the code and runs make lint. A lint failure loops straight back to the agent, which reads the message and fixes it with no human involved. Green lint runs make test, and a red suite there means behavior moved, so you revert or re-record the pin on purpose in its own commit. A green suite reaches your diff review instead. If the diff breaks a pattern you update the rule file rather than the code, per §9, and the agent re-attempts under the new rule. An approved diff goes to CI, which runs the same commands, and a red CI rejoins the same fix loop. Only green CI reaches merge, and deploying stays a separate decision. So every box here has an exit that is not forward.
Block the side door
An agent that can call the test binary directly can run a subset and report green. In that project we put a deny list in the agent's permission config, blocking direct interpreter and package-manager invocations, and left only the make targets. The agent then passes through the same gates a human pull request does.
This sounds petty until you notice how often two commands that should be identical are not, because one of them runs an extra check.
How you know it worked
- Open a pull request with a failing check and confirm it cannot merge. Do this once, deliberately.
- The agent fixes its own lint failures without you in the conversation.
- Nobody on the team has a local script that CI does not run.
When it does not work
A flaky test teaches everyone to re-run the job, and a gate people retry has stopped being a gate. Quarantine a flaky test the day you find it. A ten-minute pipeline gets bypassed under deadline pressure, so split it: fast checks block the merge, slow suites run behind it.
Cut a seam before you ask for a change
A seam is a place where you can change behavior without editing the code around it. Legacy code has few. Creating one is most of the work, and it is what gives the agent something to hold.
The general idea
Feathers defines a seam as a place where you can alter behavior without editing in that place. The definition is about testing, and it turns out to describe what an agent needs too.
Code with no seam holds its dependencies inside itself. To test it you run the whole request. To change it you read every caller. An agent facing that code has to hold the entire path in context, and when context runs short it guesses.
The same logic behind an interface has one entry point, named parameters, and a stated return. The agent reads the interface and knows what exists, what it takes, and how to call it. Clear boundaries help an agent more than documentation does, because the agent trusts code over prose.
# BEFORE: no seam. Testing approve() needs a live webhook.
class OrderController:
def approve(order_id):
order = Order.get(order_id)
order.status = "approved"
order.save()
http.post(env["CHAT_WEBHOOK"], { text: "Order " + order_id + " approved" })
# AFTER: one seam. The controller no longer knows about HTTP.
interface Notifier:
def order_approved(order)
class OrderController:
def __init__(notifier: Notifier):
this.notifier = notifier
def approve(order_id):
order = Order.get(order_id)
order.status = "approved"
order.save()
this.notifier.order_approved(order)
What to look at: the constructor parameter. After the change you can test
approve() with a fake notifier and no network, and the message format gets a
test of its own for the first time. Nothing about the visible behavior moved.
The procedure
- Write the interface first. Name the operations in the caller's language, not the vendor's.
- Write the implementation. Move the code and change nothing else.
- Register the implementation wherever your stack wires dependencies.
- Update one caller. Run the full suite.
- Repeat for each caller, one at a time, running the suite after each.
- When the last caller has moved, delete the old code.
Keep the old code in place until every caller has moved. The application stays working the whole time, which is the constraint that rules out the tidier approaches.
When you must add behavior before the seam exists
Sometimes a ticket cannot wait for the extraction. Feathers gives two techniques for that, and both keep the old code untouched.
- sprout Write the new behavior in a new function or class, tested on its own. Call it from one line inside the old code.
- wrap Rename the old function, then write a new one with the old name that calls both the original and your addition.
Both leave a tested unit behind and a smaller untested remainder. Give the agent the new unit, not the old one.
The worked example
In the Laravel codebase the code with no seam was six PHP traits. Each one mixed HTTP calls,
business rules, and configuration into a single use statement, shared across
several controllers. Each one became a service behind an interface: chat notifications, CRM sync,
document scanning, document conversion, a third-party API client, and the domain calculator.
Read past the language and the shape is generic. A trait is one way to share code without a boundary. A mixin, a base class with protected helpers, a module of free functions reaching into globals, and a 900-line utility file are the same problem wearing different syntax. The move is identical: name the interface, move the code, migrate callers one at a time.
Each extraction took about a day. The full sequence took about two weeks. The application was never broken and users never noticed, because the tests from §2 caught the two occasions when a migrated caller behaved differently.
The benefit I did not expect
Early in that project we found cross-company data leakage. Users at one company could see another company's data, because controllers scoped their queries inconsistently. The leak never reached production and two pull requests closed the instances.
The instances were not the problem. Authorization logic was scattered, so there was no single answer to "who can see what", and every new controller was a fresh chance to get it wrong. Moving the decision into one policy object per model, and every scoped query into one builder, made the class of bug hard to write. That is worth more with an agent than without one, because an agent writes new controllers faster than you can audit them.
How you know it worked
- You can test the extracted unit with no network, no database, and no logged-in user.
- The agent injects the interface instead of reimplementing the call. Check the next new file it writes.
- Deleting the old code breaks nothing.
When it does not work
The common mistake is extraction and behavior change in one commit. You then cannot tell which of the two turned the suite red. Refactor in one commit and change behavior in the next, always in that order.
Ship the new thing beside the old one
You cannot pause delivery and you cannot rewrite. So run both paths at once, gate the new one, and move users across when each piece is proven.
Why the rewrite loses
A rewrite competes with the running system for months. The old system keeps taking patches because it has users. The two diverge. You finish with two systems that both need work, which is the outcome Joel Spolsky and Fred Brooks each described decades ago and teams keep rediscovering.
Migrate instead, one route or one screen at a time, with both paths live. Martin Fowler's name for the shape is the strangler fig: the new system grows around the old one and the old one is removed piece by piece.
Four rules that make it survivable
- gate it The new path serves local, staging, and test environments only. Production keeps the proven path until a piece is ready.
- one source When a piece ships early inside the old shell, the new component stays the source of truth. The wrapper only passes the props that differ between the two contexts.
- scope it Write down where each kind of work goes: bug in the old path, bug in a migrated path, new feature. The agent needs the rule more than your team does.
- small batches One migration per pull request, through the full pipeline. Branches live for hours, not days.
The worked example
In that codebase the old path was 228 server-rendered views behind 59 controllers, and the new path was a React application mounted at one route. Six features reached production before the new path opened. Each one was a thin wrapper: it rendered the new component inside an old page shell and handed it the old URLs.
The technique is not React-specific. It works with any component model that accepts inputs from its host, which covers Vue, Svelte, and server-rendered partials. Give every difference between the two contexts its own input, so the shared component stays open to extension and closed to modification.
Why short branches are load-bearing here
A long-lived branch goes stale in the ordinary way, and with an agent it goes stale faster. The agent writes against the branch state while main moves on, so by merge day half its assumptions are wrong.
Over about three months that project ran 258 commits and 145 pull requests, most of them one extraction, one test expansion, or one fix. Small batches also bound the damage: one pull request per decision means one revert undoes one decision, and reverting 200 lines is a different afternoon from reverting 2,000.
How you know it worked
- You shipped a feature to users during the cleanup, not after it.
- Turning the new path off in production is one configuration change.
- The agent puts new work on the new path without being reminded, because §7 says where things go.
When it does not work
Two paths with two copies of the business logic is the failure. The copies drift, and the agent then learns both patterns and mixes them. Keep the logic in the shared unit from §5 and let each path own only its input and output format. If that is not possible yet, migrate the logic before you migrate the interface.
Write the rules where they are read
Scope your conventions to the part of the tree they govern. Include the anti-patterns, because on legacy code the fence matters more than the principle.
Scope beats size
One large instruction file loads everywhere. Rules about database migrations then sit in context while the agent edits a component, which wastes context and buries the twenty lines that matter. When everything is loaded, nothing is prominent.
Scope the guidance instead. One file per boundary, loaded when the agent touches a matching path. The root file holds the map and the two or three decisions that apply everywhere. The mechanism differs by tool and the principle does not: instructions have to be local and findable.
Restructuring that project's guidance moved ten directory files into ten path-scoped rule files. The root file dropped from 606 lines to 447, and no information went missing. The rules simply landed where the right tool would find them at the right moment.
What goes in one
- What this area is. One sentence.
- Where it is headed. A growth area, or legacy in maintenance.
- The pattern, as code. An agent copies an example before it reads a rule.
- The rules. Explicit, short, checkable where possible.
- The anti-patterns, each with its reason.
---
scope: app/services/**
---
# Services
Every external integration lives behind an interface in this directory.
## Pattern
Define the interface first. Name operations in the caller's language.
Inject the interface. Never inject the implementation.
## Rules
- One responsibility per service.
- Return a typed result. Never return a raw response object.
- Read configuration from the config layer, never from the environment.
## What not to do
- Do not call an external API from a controller. Use the service.
- Do not add a second public entry point to a service. Write a new service.
- Do not swallow an error. Return a failed result the caller can handle.
What to look at: the last section. Anti-patterns are fences, and an agent needs the fence more than it needs the principle. Each line here started as a mistake somebody made.
The shortest file is the most useful
The smallest rule file in that project governed the legacy controllers. It read, in full: bug fixes only, no new features, no new routes, no new views, and new features go on the new path. Those five clauses were enough. When the agent opens a legacy file, it learns in one line that it stands in maintenance territory, which a legacy codebase never tells you by itself.
How you know it worked
- The agent follows a pattern in a file you never named.
- A new engineer reads one rule file and knows the local convention.
- Your review comments stop repeating themselves.
When it does not work
Rules written speculatively go stale and get ignored, so write a rule after the agent makes the mistake, not before. And hold the harder constraint in mind: the agent reads code more than it reads documentation. If the most-touched file in the area contradicts your rule, the file wins. Clean the code the agent reads most, or the rule is decoration.
Hand over more, at fixed checkpoints
Move from approving every edit to reviewing finished work. This is where the earlier steps pay off, and it is the first point at which they do.
The two positions
In-the-loop means you approve every action, so you are a required step in every operation. On-the-loop means you set direction and constraints, review the output, and let the agent work on its own between checkpoints.
For the first two months on that project I ran in-the-loop with auto-approve off. I read every line and made inline corrections. The code was good. I was also doing most of the thinking and half the typing, which is the worse kind of pair programming and not what I wanted from the agent.
What to look at: the gold lines, which are the ones you are on. Nine of eleven messages involve you, across five separate interruptions, and you never see the checks at all. The agent asks permission per edit and per command. You sit inside its decision cycle, so your attention caps the throughput.
Same participants, same task. What moved is where you appear. Three gold exchanges instead of five: you state the work, you approve the test descriptions, and you review the diff. The agent now transacts with the checks itself, including the red run it fixes without telling you. You approve the specification and the result, and the checks approve everything in between. That is the entire difference, and it only holds because §2 through §7 made the checks worth trusting.
The precondition
Do not move here until you have finished §2 through §7 in the area you are working. On-the-loop without a floor produces fast slop instead of slow slop, and it is still slop. Your trust goes to the checks, not to the model.
The eight checkpoints
- 1 · scopeWhich path does this touch: the legacy one, the new one, or both? Answer before anything is written.
- 2 · requirementsRead the problem statement the agent drafted from the ticket. Correct it.
- 3 · planRead the file list and the testing strategy. An architectural mistake is cheapest here.
- 4 · test namesRead the failing test descriptions. They are the specification, so spend your time here.
- 5 · implementationRead the summary of what changed and why.
- 6 · the diffRead the actual diff. Look for architecture, not formatting. §3 already handled formatting.
- 7 · findingsThe agent reviews its own change. Choose which findings to act on and which are noise.
- 8 · refactoringChoose which cleanups earn a separate commit. Apply the rule of three.
Between those eight points the agent runs alone. It writes the code, runs the checks, fixes the failures, manages the branch, and opens the pull request without asking. Codify the sequence in a reusable workflow file once it stabilizes, so the ceremony stops depending on what you remember to type.
How you know it worked
- Your review time drops while your review level rises. You are reading architecture, not semicolons.
- The agent runs the checks before it shows you anything.
- You can name the last change you reverted, and it was one pull request.
When it does not work
Three failure modes, and all three are yours rather than the agent's. Your rules encode a bad pattern, so the agent reproduces it faithfully and everywhere. Your tests check implementation details, so the agent passes them while doing the wrong thing. Or you stop reviewing, which is the common one. On-the-loop is not no-loop, and the checkpoints are the entire design.
Fix the rule, not the instance
When the agent gets something wrong, change the rule that allowed it. Then re-run the change under the new rule. This one habit compounds harder than any other on the page.
The protocol
- Update the rule file that governs the area.
- Reload it into context.
- Re-attempt the change under the updated guidance.
The order carries the whole benefit. Fixing the code fixes one file. Fixing the rule fixes every file after it, in this session and in every session that follows, because the rule file lives in the repository.
One example. The agent kept calling the chat webhook straight from controllers instead of going through the notification service. I added a line to the services rule file: do not call external APIs directly from controllers, use the appropriate service. It never made that mistake again, in any controller.
What it costs you
It forces pattern-level feedback, because "Fix this line" cannot become a rule while "Notification logic belongs in the notification service" can. Writing the second kind takes longer per review, and it pays back on every review after. So the mechanism shapes your feedback toward the reusable form, whether you meant it to or not.
The honest measurement
Early on I gave feedback on almost every diff. Three months in, most diffs needed none. I have no before-and-after metrics for that, because it was never set up as an experiment. The direction was clear. The size of it is something I did not measure and will not invent.
I built the guidance files at commit 109 of 258. They should have arrived around commit 30, right after the tests and the linting. Everything between those two points was work the agent did without guidance it could have had. I kept re-correcting the same mistakes for weeks longer than I needed to.
What the tests do not cover
The suite proves that the behavior you recorded did not change. It proves nothing about behavior you never recorded, and on legacy code that remainder is always large.
The question, and the honest answer
You pinned the area, the agent refactored it, and both the suite and CI came back green. So how do you know it did not quietly break something the tests do not cover?
You do not know. Coverage on a legacy system is partial and will stay partial, so certainty is not on the menu. Anything that sounds like certainty here is a sales pitch.
What green actually means
- provenEvery behavior you wrote an assertion for still holds.
- provenEvery rule your linter and type checker encode still holds.
- unprovenAny behavior in the changed area that you never recorded.
- unprovenAny consumer living outside this repository: a report, a scheduled job, another team's integration.
- unprovenAnything about performance, unless you measured it before and after.
Coverage is a map of what you checked. It is not a claim about the territory you skipped.
Four moves that shrink the gap
Pin the area you are about to change rather than the whole system. A partial map is worth having where the work is happening, and completeness was never available.
Run the real dependencies. A substitute engine hides exactly the class of break that only the production engine catches, which is the mistake I made first and caught by accident.
Break each test on purpose before you trust it. Change one line under it and confirm the suite goes red. A test that stays green was never a check.
Read the diff yourself. Automated checks cover the categories you already encoded, so a human reading the change is the only check that can notice a category nobody thought of.
Three moves that make the gap survivable
Assume some breaks get through, then make them cheap. Small batches mean one revert undoes one decision. Then a gate between merge and production means every merge is deployable while deploying stays a decision a person makes. And environment gates and feature flags mean new code can live in the repository and run in staging without serving a single user.
Read-only review agents help at the margin. That project ran five in parallel on each diff, each with a different brief: functional, structural, security, risk, and deployment. They catch things the automated checks miss. They are not proof, they disagree with each other, and the disagreements are the part worth reading.
The residual
Here is what remains after all of it. A refactor can change behavior that nobody recorded, no check encoded, and no reviewer noticed. That will happen to you. The floor in this page does not make it impossible. It makes it rare, detectable when it does happen, and cheap to undo.
If you need a stronger claim than that, look elsewhere. I do not have one, and I have not seen one that survived contact with a system this old.
Pick the one module you most want the agent to change. Write characterization tests for it, against the real engine, and break each one on purpose. That is a day of work, it ships nothing, and it is the step that changes what you can safely hand over next week.
The open question I have not answered: how much of the unpinned remainder is actually load-bearing. I know how to shrink it. I do not know how to measure what is left, and I would like to.