Harness Engineering / Migrating Without Lying / method

A migration gives the code two right answers.

Charters describe the codebase, so a migration puts every charter under strain. Write the target and the charter lies about most of the files. Write the present and the agent re-fattens the controllers you already thinned. The way through: keep recording today, say a migration is open, and link the plan on the same line. Then give the agent one criterion for which side a change lands on. The payoff is a migration that can pause for a quarter and resume.

who it's for · anyone keeping a CLAUDE.md honest while the codebase sits between two patterns
the worked example · thinning controllers. 23 units, a Rails-shaped app. The pattern swaps
what it costs · three charter lines and one feature doc, for as long as the migration runs
pairs with · Anatomy of a Charter (the format) · The Migration Harness (the mechanical bulk)
license · CC BY 4.0. Take the format, not the example
Today's truth
What the charter records. During a migration that includes the half-moved state, said plainly.
The migration doc
One feature doc holds the plan, the target, the position, and the ledger. The charter links it and copies nothing.
The criterion
Which side a change lands on. Mechanical, so two runs on one task answer the same way.
0 The premise

A charter is a description, and a migration breaks descriptions

A design migration runs for months. For those months two patterns are correct, and the charter has one field to say so in.

Start from what the agent reads. It reads the charter, and it reads the code near the file it is changing. Those two are the same document in a settled codebase, which is why the charter works at all. A migration pulls them apart, so the agent has to choose, and every way of choosing goes wrong differently.

three ways to write one charter sectionall three are wrong
A. THE CHARTER STATES THE TARGET
   "Controllers stay thin. Logic lives in operations."
   → true of 9 files, false of 14
   → the agent writes an operation nothing else resembles
   → review reads it as inconsistent, and asks for it back

B. THE CHARTER STATES THE PRESENT, AND HIDES THE MIGRATION
   "Business logic lives in controllers."
   → the 9 moved files are now the odd ones out
   → the agent finds one, corrects it, and calls that cleanup
   → the migration loses ground on unrelated tickets

C. THE CHARTER STATES BOTH, WITH NO CRITERION
   "Prefer operations for new code where it makes sense."
   → "where it makes sense" is a judgment, so it varies
   → two runs on one task pick different sides
   → the reviewer cannot say which answer was correct

B is the one that costs real money, because nothing about it looks like damage. The diff reads as an ordinary tidy-up. Nobody flags it in review, and the count of moved files goes down while the ticket it rode in on goes green.

C is the one teams actually write, because it sounds reasonable and it is fast to type. It also hands the agent a design decision on every turn, which is the decision you were trying to make once and write down.

What the agent is missing in all three

Not a better rule. It needs to know that a migration is open, that two patterns are legal today, and which one applies here. Those are three facts, and the charter carries all three in a few lines.

1 The method

Three moves, and the charter never stops being true

The charter always describes today. The migration doc carries everything about tomorrow. When the migration lands, today becomes what the doc was describing.

charter today, plus a pointer feature doc all migration state check migration.side
  1. 1. Record today The charter states the pattern that most of the code uses right now. On the same lines, it states that a migration is open and links the doc. It does not state the target, because the doc states it.
  2. 2. Hold the state One feature doc holds the target rule, the plan, the criterion, the ledger of units, the escape hatch, and the done-when. It lives in the repo, humans keep it current, and the pointer makes its target rule law for the units already moved.
  3. 3. Land it The last ledger row closes. The charter states the target as the present truth, and the rule's scope widens from the moved units to every file. The doc moves to done and stays readable as history.

The split is not filing preference, because the two files get read on different schedules. The charter enters context on every turn, so its budget is tight and every line competes. The feature doc gets read when the task touches that feature, which is when the detail is worth its tokens. So the charter carries the pointer, and the doc carries the ledger and the rule the moved units answer to.

.claude/features/ is the natural home for the doc, next to whatever else the agent reads on demand. Still, the location matters much less than the link. Put the pointer on the same lines as the truth it qualifies. That way an agent reading the controllers section finds the migration where it is already looking.

A pointer in the wrong place is not a pointer

A "current migrations" list at the bottom of the charter looks tidy and fails in practice. The agent reads the controllers section, gets a complete-sounding answer, and acts on it. Nothing sends it further down the file. Adjacency is the whole mechanism, so keep the link inline even when that reads as repetition across sections.

What the bookkeeping buys, and what it does not

It buys nothing on speed. The cost is a file, three charter lines, and one extra pull request per unit. What it buys instead: every state the migration passes through is legal, so the work can stop for a quarter and resume. Position sits in the ledger as a fact, rather than in somebody's memory. A large refactor usually dies because it needs continuous attention, which this one does not. So abandoning it halfway still leaves a documented codebase in two patterns, rather than an undocumented one in three.

2 Present tense

The charter is in the present tense, always

An aspirational charter reads well and behaves badly. It describes a codebase that does not exist yet, so it cannot be checked against the one that does.

The case for writing the target is not stupid. You want every new file to match the target, the charter is where the agent looks, and stating it there is one line of work. That instinct is right about the goal and wrong about the mechanism. The criterion in §5 gets the same behavior for new code. It also keeps the charter true about the other fourteen files.

Falsity has a specific cost here. A charter you can check against the code is a charter a reviewer can enforce and an agent can trust. Once one line is aspirational, every line is suspect, and the next person to hit friction argues that this rule is also just a plan. That argument is hard to answer, because it is partly correct.

The three facts, and where each one lives

one fact per file, and no fact in two files
FactLives inWhy not the other file
What the code does today charter The agent needs it on every turn, including turns that never touch the migration.
That a migration is open charter An agent that has to open a doc to learn a migration exists will not open the doc.
The target rule and the position feature doc The position changes weekly, and a weekly edit to the charter is how charters rot. The pointer makes the target rule binding from there.

That last row is where I get it wrong, because I have wanted the ledger in the charter for the visibility. Resist that pull. A ledger in the charter means a charter commit every time a unit moves, and that churn stops anyone reading it as law.

3 The entry

The charter entry, before and during

Thinning controllers, as a worked example. Opening the migration adds two sentences and a path. It does not change the sentence that was already true.

CLAUDE.md · before the migration opensthe old truth
## Controllers
Business logic lives in controllers. Models stay thin and hold
associations, scopes and validations only.
  none describes the codebase, nothing enforces it
  2024-09-30 · standing · how this app has always been built
CLAUDE.md · the same section, migration opentwo sentences and a path
## Controllers
Business logic lives in controllers. Models stay thin and hold
associations, scopes and validations only.

MIGRATION OPEN. Controllers are moving to operation classes.
Read .claude/features/thin-controllers.md before you write or change
any controller. That file names the criterion. Apply it without
being asked, and say in the PR which side the change landed on.
  check migration.thin-controllers.side
  2026-04-02 · MIG-3 · opened. closes when the ledger has no old rows

Three things in that block are doing work. The old truth stays first and stays unqualified, because it is still how fourteen of twenty-three controllers behave. That notice sits in this section rather than a global list, so an agent reading about controllers cannot miss it. The unprompted instruction is there because "read the doc if relevant" reads as optional. A criterion nobody applies is a criterion you do not have.

Say which side, in the PR

One sentence, in the description, naming the side and the ledger row that decided it. It costs the author nothing and it turns a silent design choice into a reviewable claim. It also catches a criterion that has drifted from the codebase, because a reviewer argues with a wrong side once the author writes it down.

4 One file

The migration doc, in full

Seven sections. They answer where we are, where we are going, what has to land first, how to decide today, and what closes it. Anything else belongs in the ticket.

.claude/features/thin-controllers.mdthe artifact
# Migration · thin controllers
status  open · 9 of 23 units moved · opened 2026-04-02 · MIG-3

## Where we are now
Business logic lives in controllers. 23 controllers hold it. 9 have
moved to operation classes. 14 have not. Both shapes are correct
today, and the criterion below says which one your change uses.

## The rule for the new side
Read this section as the charter entry for every unit the ledger
marks `new`. It is law for those files, not a plan for them.

A controller parses params, calls one operation, and renders. It
holds no branching on domain state and no writes.
  gate controllers.no-writes · scope: units marked `new`
  2026-04-02 · MIG-3 · scope widens to the whole charter when the ledger empties

    def create
      result = Billing::CreateInvoice.call(invoice_params)
      result.ok? ? render_invoice(result.invoice) : render_error(result)
    end

## Plan
Ordered by risk, then by dependency. Supporting work gets its own
phase, and it lands before the units that need it.

  phase 1 · extract Result and the operation base   prereq  · done
  phase 2 · read-only controllers, nothing to unwind    9 · done
  phase 3 · single-write controllers                    8 · in progress
  phase 4 · land money.cents on the money helpers   prereq  · in review
  phase 5 · money paths, needs phase 4                  4 · blocked
  phase 6 · split ReportsController, then move it       2 · not started

## The criterion
1. New controller, or new action on any controller → new side. Always.
2. Existing action → the ledger row for its controller decides.
3. One change lands on one side. No change straddles.
4. Moving a unit is its own change and alters no behavior.
5. No unit moves before its phase's prereq lands. Check the plan.
6. Not in the ledger and not new? Stop and ask. Do not guess.

## Ledger
| unit                 | side | moved      | note                     |
|----------------------|------|------------|--------------------------|
| InvoicesController   | new  | 2026-04-08 |                          |
| PaymentsController   | new  | 2026-04-11 |                          |
| SubscriptionsCtrl    | new  | 2026-04-24 |                          |
| ... 6 more new rows       |      |            |                          |
| RefundsController    | old  | —          | blocked · phase 5        |
| WebhooksController   | old  | —          |                          |
| ReportsController    | old  | —          | 900 lines, split first   |
| ... 11 more old rows      |      |            |                          |

## The escape hatch
A change may land on the old side of a `new` unit. Add a note to
that row, with a date and a reason. Two notes on one row means the
target shape is wrong. Stop the migration and fix the target.

## Done when
Every row reads `new`. Then: the charter states the target as the
present truth, the `migration.thin-controllers.side` check is
deleted, and this file moves to `.claude/features/done/`.

The status line sits at the top, because it answers the most common question. A stale status is also easier to spot there. Everything below it exists to answer one agent turn, so nothing in the file argues for the migration. The case for the migration belongs in MIG-3, where people who need convincing will look for it.

Notice what the target section is doing. It carries a rule, an altitude, a scope, and a date, in the same format the charter uses. So the charter's pointer hands over authority for one side of the migration, and it does not merely describe a plan. An agent on a new unit reads the doc and finds law there. That is the only reason the charter can stay silent about the target.

I keep the plan and the position in one file, not a rule per directory. Two places to look for one answer is the failure this method removes. The escape hatch is the other deliberate choice: a sanctioned exception with a date beats an unsanctioned one. So I set the alarm at two notes rather than one, and a single awkward file cannot stall the whole migration.

The plan and the ledger answer different questions

The plan is phase-level, and it holds the order, its reason, and how far the team has gotten. The ledger is unit-level and it answers one question per file. Progress reads off the plan and today's decision reads off the ledger, so neither does both jobs badly.

Ordering by risk is half of it. Read-only controllers move first, because a mistake there unwinds cheaply. A plan ordered by convenience moves the easy units first, and it leaves every hard one for the month the team runs out of patience.

The other half is dependency, which is why two phases here move no controllers at all. Phase 4 is the money helper the money paths need. It sits in the plan as a numbered phase, so somebody owns it and its status shows. A plan that lists only units hides that work. Then the prerequisite surfaces halfway through a move, and the unit stalls in neither shape.

Who keeps it true

  • Humans define the migration. A person writes the target, sets the criterion, and owns the plan and the ledger. Agents read all four and rewrite none of them.
  • The row flips in the move's commit. Whoever moved the unit changes its row in the same change. So no checkout holds a ledger that disagrees with its own code.
  • The doc ships with the code. It is a versioned file in the repo, so every teammate, clone, worktree, and agent session gets the current state. Nobody has to remember to mention the migration.

That last one is why the file belongs in the repo and not in a wiki. A branch that moves three units carries its own ledger, so merging it brings the state along with the code that made it true. The next task on a controller then finds the migration already in front of it, and a task on the mailer never pays for it.

What the doc does not hold

  • The justification. That is the ticket's job. A doc that argues invites the agent to weigh the argument, and you wanted it to apply a rule.
  • The per-unit recipe. A paragraph on how to move each of twenty-three controllers will be wrong by the third one. The plan gives the order and the target gives the shape, so whoever moves a unit works out the rest.
  • A completion date. The plan shows order and the ledger shows position, and both are facts. A date is a guess, and a missed guess makes readers distrust the facts next to it.
5 Load-bearing

The criterion is the part that has to be mechanical

Every other section here is bookkeeping. This is the part that decides whether two runs on the same ticket produce the same design.

A criterion is good when a reader with no context can apply it and get the same answer you would. Four properties get you there, and each one rules out a criterion that sounds fine in a standup.

  1. Mechanical It reads a fact, not a situation. A ledger row, a path glob, or a file's location. Never "where it makes sense".
  2. Single-sourced One file answers it. If a directory rule and a ledger can disagree, you have shipped the ambiguity you were removing.
  3. Total Every change gets an answer, including new files and files nobody listed. Rules 1 and 6 in §4 exist for the two gaps.
  4. Checkable A hook can read the ledger and compare it against the changed paths. Then a straddling change fails before review, not during it.

Four criteria that fail, and how

each of these comes up in a real design discussion
CriterionHow it fails
"Prefer the new pattern" A preference is a judgment. The agent resolves it differently per run, and the reviewer has no ground to stand on.
"Migrate whatever you touch" Every change straddles by design. Each PR then mixes a behavior change with a move, so a revert takes both.
"Migrate a file on its third change" Needs commit history the agent has no reliable read on, and the answer shifts between two people on the same file.
A path glob, when units are directories Does not fail. It is the cheapest criterion that works, so use it and skip the ledger.

That last row is the boring option and it wins whenever the migration unit lines up with a directory. app/billing/** is new, everything else is old, and the criterion is one line with nothing to maintain. The ledger earns its keep when units cut across the file tree. That is what makes controller thinning a fair example rather than a flattering one.

Rule 3 is the one people push back on

"No change straddles" feels wasteful, because you are in the file anyway. It buys you reverts that work, reviews that ask one question, and a ledger you can trust. A half-moved unit belongs to neither side. That is the state teams abandon a migration in, leaving the codebase in three patterns instead of two.

6 In practice

Three tickets, three different answers

The same criterion, applied to the same ledger, on three ordinary tasks. Only one of them moves a unit.

the agent's decision, three timessame rule, same ledger
TICKET · add a discount field to invoice creation
  read ledger → InvoicesController · new
  → new side. the change goes in Billing::CreateInvoice.
     the controller keeps parsing params and rendering.

TICKET · fix rounding on refund totals
  read ledger → RefundsController · old · blocked · phase 5
  → old side. the fix goes in the controller, as it stands.
     do not move the unit to fix a bug. rule 4.

TICKET · add retry handling to inbound webhooks
  read ledger → WebhooksController · old
  read plan   → phase 3 · no open prereq, so a move is allowed
  the new code is ~120 lines and the move would rewrite it
  → split into two changes.
     PR 1 · move WebhooksController. no behavior change. flip row.
     PR 2 · add retry handling on the new side.

The second answer is the one that feels wrong and is right. Fixing a rounding bug inside a controller you plan to delete looks like waste. Still, it keeps the fix small, reviewable, and cherry-pickable to a release branch. The move happens when the ledger says so, not when a bug routes someone through the file.

The third answer needs a decision rule, because rules 1 through 6 do not settle it on their own. Move first when the move would throw the new code away. The move rewrites a hundred and twenty lines written against the old shape. So you pay for that code twice, and review it twice. A three-line change survives the move, so it lands on the old side and waits.

The agent can run all three of these

That is the point of the criterion being mechanical. Read the ledger, match the row, pick the side, and state it in the PR. The one case that comes back to a human is rule 6, when the unit is not in the ledger at all. That question is worth an interruption, because it usually means the ledger has drifted from the codebase.

7 Landing

Closing the migration, and the promotion you get for free

The last row flips. A gate was already running on the moved units, so landing the migration widens its scope instead of inventing it.

CLAUDE.md · after the last row flipsthe new truth
## Controllers
A controller parses params, calls one operation, and renders. It
holds no branching on domain state and no writes.
  gate controllers.no-writes        ← same gate, same id
  2026-04-02 · MIG-3 · migrated 23 units. completed 2026-06-19
                                  the scope line is gone

Look at what moved and what did not. The target rule was a gate throughout, scoped to the units the ledger marked new. A charter-wide gate would have refused fourteen controllers a day, and the team would have learned to bypass it. That scope is what keeps a gate honest before the migration finishes. Now it covers the directory, and the ledger has nothing left to decide. The migration.side check is the rule that goes away, because every change lands on the only side there is.

So the migration hands you a promotion, in the sense that promotion to the harness uses the word. This one promotes by scope rather than by altitude. The rule was a gate from the first unit moved, and it ends as a gate over all twenty-three. Each row the team moved widened what the gate could honestly refuse, which is the only order that works.

Five closing chores, none optional

  • Move the target rule into the charter. Copy it out of the doc, drop the scope line, and keep the gate's identifier. The rule has not changed, so its name should not either.
  • Delete the migration notice. Both sentences and the path. A pointer to a closed migration sends the agent to read a file that no longer describes anything.
  • Delete the migration.side check. It now passes for every change, so it costs runtime and teaches nothing.
  • Archive the doc, keep it readable. .claude/features/done/. It is the record of what moved and when, and deletion day will want it.
  • Delete the old-side code paths. Search for the base class, the shared concern, the helper nobody calls now. A migration with the old scaffolding still standing is one that can reverse without anyone deciding to.
The charter is one line longer than it was

Under a fixed charter budget the migration is not free while it runs, and it costs three lines. After it closes it costs one, and that line replaces the one it displaced. A team running four at once spends a third of its charter on plans. Finish one before opening the next.

8 Skip it

Four migrations that do not want a document

The method costs a file, three charter lines, and a ledger somebody has to keep accurate. Four common cases are cheaper without it.

  1. Under five units Do it in one change and skip the ceremony. A ledger with four rows is a to-do list, and the charter never has to hold two truths at once.
  2. One mechanical pass A rename or a codemod that lands complete in a single commit. There is no half-state to describe, so the diff is the whole document. See the migration harness for the version with residue.
  3. No agent in the loop The method buys less when only humans touch the code, because a human notices the migration from the code review. The ledger still helps. The charter lines matter less.
  4. Target still undecided A doc with a vague target section teaches the agent to guess, which is worse than nothing. Write "spike open, no migration" in the charter, and decide first.

The fourth case is the one worth watching for. An open migration with an unsettled target is how a codebase ends up in three patterns. The criterion still routes changes, so work keeps landing on a "new side" whose shape is moving underneath it. Two notes on a ledger row is the alarm for that, and §4 says to stop when it fires.

I would rather write the doc slightly too early than slightly too late. A migration that outgrows a five-unit guess is still cheap to write down. Two months of undocumented half-state is not, because someone who was not there has to reconstruct it from the code.

Start with the sentence you cannot write

Take a migration your team has open now, and try to write today's truth in one sentence that is true of the whole codebase. If you can write it, you probably do not need any of this. If you cannot, the agent has been guessing on every turn since the migration opened, and the doc needs to exist before the next one.