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.
CLAUDE.md honest while the codebase sits between two patternsA 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.
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.
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.
The charter always describes today. The migration doc carries everything about tomorrow. When the migration lands, today becomes what the doc was describing.
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 "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.
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.
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.
| Fact | Lives in | Why 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.
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.
## 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
## 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.
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.
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.
# 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 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.
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.
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.
| Criterion | How 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.
"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.
The same criterion, applied to the same ledger, on three ordinary tasks. Only one of them moves a unit.
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.
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.
The last row flips. A gate was already running on the moved units, so landing the migration widens its scope instead of inventing it.
## 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.
migration.side check. It now passes for every change, so it costs runtime and teaches nothing..claude/features/done/. It is the record of what moved and when, and deletion day will want it.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.
The method costs a file, three charter lines, and a ledger somebody has to keep accurate. Four common cases are cheaper without it.
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.
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.