Harness Engineering / The Migration Harness / worked example

Ten thousand files. Nine thousand are a codemod.

Large mechanical migrations are the best thing agents do, and the way most teams attempt them throws that advantage away. Pointing an agent at ten thousand call sites is slow, expensive, non-deterministic and unreviewable, and it spends the judgment-capable tool on work a syntax tree handles perfectly. The design that works splits by determinism: a codemod takes the mechanical majority in one reviewable diff, and the agent gets only the residue that needed a decision.

who it's for · anyone facing a five-figure number of call sites and a deadline
the migration below · float dollars to integer cents. The follow-on work from the rule in the charter
pairs with · The Factory (the queue) · Five Guardrails (the gates) · The Audit Trail
the honest limit · §8, including the case where you should not do this at all
Mechanical
A deterministic transform with one correct output. Codemod. One diff, reviewed in bulk by shape.
Residue
Needs a decision the syntax tree cannot make. One agent unit each, reviewed one by one.
Policy
A question for a human before any code runs. Not a coding task at all, and the thing that sinks migrations.
0 The trap

The obvious approach is the expensive one

"Fan an agent out over the whole codebase" sounds like the thing agents are for. It is the one design that gets you the worst properties of both tools.

Run an agent over ten thousand files and every single one becomes non-deterministic. Two runs produce different diffs, so a file that was correct on Tuesday comes back changed on Thursday. Nothing is reviewable in bulk, because no two changes match in text even where the transformation was the same. And you have paid inference for nine thousand transformations that a syntax tree would do for free, in eleven seconds.

The opposite failure is just as common. A team writes a codemod, it handles eighty percent, and two thousand sites remain. Those sites become a spreadsheet that three engineers grind through for a quarter. Agents are good at that work. The team is doing it by hand, because it framed the codemod as the whole solution, not the first stage.

three designs, one of them workssplit by determinism
ALL AGENT      10,412 units · non-deterministic · unreviewable in bulk
                pays inference for 9,100 transforms an AST does for free

ALL CODEMOD    9,100 done in one diff · 1,312 left in a spreadsheet
                a quarter of three engineers grinding the tail by hand

SPLIT          codemod: 9,100 in one reviewable diff, deterministic
                agent:   1,312 units, one decision each, individually reviewed
                the expensive tool only touches the expensive problem
The organising principle

Split the work by whether it has one correct answer, not by directory, team, or file count. A transformation with one correct output belongs in a codemod forever, however many of them there are. A transformation that requires choosing belongs to an agent, however few.

1 Census

Count the shapes before writing anything

You do not have one migration. You have four, and two of them are not migrations at all.

Before any transform, write a script that classifies every call site into a shape. Not a sample: all of them. That script costs an afternoon, and it is the difference between a plan and a hope.

census · float dollars to integer cents · 10,412 sites
ShapeExampleSitesClass
A · arithmetic total * qty, a + b on money fields 6,800 mechanical
B · display f"${total:.2f}", division by 100 for output 2,300 mechanical
C · boundary rounding invoice totals, carrier settlement, tax lines 890 residue
D · tolerance comparison abs(a - b) < 0.01 422 residue
mechanical 9,100 (87%) · residue 1,312 (13%) 10,412

What the census tells you

  • Shape D is not a migration. Every one of those tolerance comparisons exists because somebody was papering over float error. In integer cents the tolerance is meaningless, and each site is a question: was that comparison hiding a real discrepancy? Some of them were, which makes it a bug hunt wearing a migration's clothes.
  • Shape C is a policy question first. Where exactly does rounding happen, and in whose favour? Until a human answers that question, no tool of any kind can do the work correctly. That is the brick-coloured category, and it is the thing that sinks migrations.
  • The ratio is the plan. 87/13 says write the codemod. If the census had come back 40/60, the whole approach would be wrong. You want that number before you build anything.
Resolve the policy before the code

Until someone with authority writes down the rounding rule, do not start the 890 boundary sites. Start them first and you get 890 decisions, each one plausible and none of them consistent with the rest. That inconsistency is worse than the float problem you started with, and much harder to detect.

2 9,100

The codemod, and its refusal list

The codemod's most important job is to refuse. It refuses anything it is not certain about, and it writes the refusal down.

the transform, with an explicit bail-outdeterministic
def transform(node, ctx):
    if not is_money_expr(node):
        return None                      # not ours

    if has_tolerance_compare(node) or crosses_boundary(node, ctx):
        ctx.refuse(node, reason="shape-C" if ... else "shape-D")
        return None                      # hand to the agent, do not guess

    return to_cents(node)                # one correct output

A codemod that tries hard produces the worst outcome available. Most changes in the diff are correct, a few are wrong, and nothing marks which is which. Nobody can review a diff of that shape at ten thousand files. A codemod that refuses produces two artifacts instead: one you can review in bulk, and one worklist.

refusals.jsonl · the agent's input queuethe handoff
{"path":"billing/invoice.py","line":211,"reason":"shape-C",
 "expr":"round(subtotal * tax_rate, 2)","sha":"6c1a2e0"}
{"path":"billing/recon.py","line":88,"reason":"shape-D",
 "expr":"abs(paid - owed) < 0.01","sha":"91be004"}
Review the mechanical diff by shape, not by file

Nine thousand changes are reviewable when every change has the same form. Read twenty of shape A, confirm they match, and the argument covers all 6,800. An agent-produced diff destroys that uniformity, which is the biggest reason to keep this stage deterministic.

3 The unit

One file, one commit, one decision

The unit of agent work is the smallest thing that can be correct, reviewed, and reverted on its own.

  1. Scope One file. Not one module, not one "area". You cannot revert a unit that touches four files without considering the other three.
  2. Input The refusal record, the file, the policy decision from §1, and nothing else. No repository-wide context: the unit does not need it, and wide context is where scope creep enters.
  3. Output One commit, plus a one-line justification naming which policy branch it applied. That line is what makes 1,312 reviews survivable.
  4. Escape hatch The unit may return UNCLEAR with a question. Roughly one in twenty will, and those are the interesting ones. An agent forced to always produce a change will produce a plausible wrong one.
The escape hatch is not optional

Without it, every ambiguous site gets a confident answer. And in a diff, nothing separates a confident answer to an ambiguous question from a correct one. With it, you get a shortlist of sixty sites that need a human and eleven hundred that do not.

4 Fan-out

A queue, and one writer per file

The fan-out is the factory pointed at a finite backlog. The only new problem is that the backlog is finite, which makes idempotence cheap and restarts routine.

the worker looprestartable by construction
def work(item):
    # idempotence key: path + the sha it was queued against
    key = f"{item.path}@{item.sha}"
    if done.has(key):
        return SKIP                       # re-runs are free

    if head_sha(item.path) != item.sha:
        return REQUEUE                    # file moved under us

    result = agent_unit(item)
    if result.status == "UNCLEAR":
        dlq.put(item, result.question)    # human worklist
        return

    commit(result.diff, msg=f"cents: {item.path} ({result.branch})")
    done.put(key)

Four properties that make the fan-out survivable

  • One writer per file. Partition the queue by path. Two workers on one file is the only true race here, and partitioning removes it without locks.
  • Idempotence on path plus sha. A rerun is a no-op, so you can kill the whole fleet at any moment and start it again. Migrations run for days, so a restart will happen.
  • Requeue on drift. The repository keeps moving while you work. A file whose sha changed goes back in the queue rather than getting a diff computed against a stale reading.
  • The dead-letter queue is the deliverable. Not a failure log. It is the ranked list of sites that need a person, and that list is the output you wanted.
Bounded concurrency, and not for cost reasons

Twenty workers, not two hundred. Human review throughput is the limit that binds first, so fourteen hundred commits on Monday buy nothing if the team can review sixty a day. Match the fleet to the reviewers: the queue drains on exactly the same date, with less rework.

5 The oracle

The old implementation is still there. Use it.

In a migration, a perfect reference implementation of the correct behaviour already exists. Almost nobody uses it.

Unit tests check the cases somebody thought of. A differential test checks the cases that happen, which for a billing system means a year of real transactions. Replay them through both implementations and compare.

differential replay · the strongest check available herethe real gate
for txn in replay(last_365_days):          # 4.1M recorded inputs
    old = legacy.total(txn)                # float dollars
    new = cents.total(txn) / 100           # integer cents, for compare

    if abs(old - new) > 0.005:
        report(txn, old, new)              # a real behaviour change

# Expected output is not "zero differences". It is a small set of
# differences you can explain one by one, because the point of the
# migration was that the old behaviour was wrong.
Zero differences means you have not migrated anything

Suppose the new implementation agrees with the old one everywhere. Then the migration is cosmetic, or the replay misses the paths that matter. Instead, the valuable output is a list of two hundred transactions where the answers differ. Explain each difference ("old code lost a cent on this rounding, that was the bug") or investigate it.

Where no recorded inputs exist, generate them: property-based testing over the input domain, comparing old and new. Generated inputs are weaker than real traffic and much stronger than your existing unit tests, because both implementations can disagree.

6 Landing

Never one pull request

Nobody can revert a ten thousand file change, so nobody should merge one, however green it is.

  1. First Add the new representation alongside the old. A total_cents field written in parallel with total, both maintained. Nothing reads the new one yet.
  2. Then Migrate leaves before callers. Modules nobody imports go first, so each merge touches the fewest dependents. The dependency order does the sequencing for you.
  3. Per merge One shape, one module, differential replay attached to the pull request. Reviewers read twenty representative changes, then every residue commit one by one.
  4. Last Delete the old field. That deletion is the only irreversible step. It happens weeks after the code lands, once the parallel writes have agreed in production long enough to believe them.

Running both representations in parallel feels wasteful and is the cheapest insurance available. It gives you a production differential test: every write compares the two answers, and a mismatch alerts. That test catches the paths your replay corpus missed, which are the paths nobody thought about.

The migration is done when the old field is deleted

Not when the new one works. Teams reach ninety-five percent, declare victory, and leave both representations in place forever. That end state is worse than either representation alone. Put the deletion on the calendar at the start, with a name against it.

7 The ledger

What the split bought

The interesting number is not the time saved but where the human attention went.

where the effort landed, 10,412 sites
StageSitesMachineHuman
Census10,4121 script, minuteshalf a day reading the output
Policy decision890noneone meeting, one written rule
Codemod9,100seconds per run2 days to write, 1 day to review by shape
Agent residue1,2521,252 units~5 days of individual review
Escalated UNCLEAR60none2 days, and worth every hour
Total10,412~11 working days

Two thirds of the human time went to the thirteen percent that needed judgment. The sixty escalations got two full days, and that distribution is the entire point. The comparison here is not against doing the work by hand: it is against the all-agent version. That version would cost eight times the inference and produce a diff nobody can review in bulk. It would answer those sixty questions instead of asking them.

8 Don't

Four migrations that do not want a harness

Reaching for agents on anything large is the same reflex as reaching for a gate when a sentence would do.

  1. All mechanical If the census comes back 100% mechanical, you need a codemod and a reviewer, and nothing else. Building a fan-out for it is elaborate procrastination.
  2. A rename Your language server does the rename correctly and for free, with full type awareness. No version of a rename needs an agent.
  3. No oracle No recorded inputs, no old implementation to compare against, no property you can state. Then nothing verifies the result at scale, and you ship ten thousand unreviewed changes with extra steps. Build the oracle first or do not start.
  4. Unresolved policy The 890-site question from §1, unanswered. No amount of tooling substitutes for somebody deciding where rounding happens. Start anyway and you get inconsistency that is far harder to find than the original problem.
Where to start

Write the census script and nothing else. One afternoon, no commitment. It answers the one question that shapes the whole project: what fraction of this migration has one correct answer? Everything on this page follows from that number, and teams that skip the census choose an architecture before they have the fact that decides it.