Your migration slid backwards.MM3 saw it before you did.
A real run: an agent moves two routes off an old module, one commit each. Then one more commit sends a route back. Every yes/no question still passes. Only a count, compared with the step before, gives it away.
your coding agent
>migrate the /orders and /invoices routes off the legacy module one step at a time, using a strangler fig, committing after each step
1sentence from you
2steps, one commit each, by the agent
9 of 9yes/no questions still passed on the commit that slid back
$0.00017for the whole history, about (estimated from tokens)
↓ Scroll. The panel follows the story.
1 · You
You type one sentence.
Your order service has three routes, /orders, /customers and /invoices, and one old module answers all of them. You want two of them off it, safely, one at a time. Type: migrate the /orders and /invoices routes off the legacy module one step at a time, using a strangler fig, committing after each step.
A strangler fig is a way to replace an old system without a big-bang rewrite. A router sits in front; you build the new part beside the old one and move one route at a time, until the old part can go. Martin Fowler named it after the figs he saw in a Queensland rainforest, and today the term “is now often used to describe a gradual approach to legacy modernization” (Fowler, “Strangler Fig”).
The standard picture: commit by commit, the router sends more paths to the new part and fewer to the legacy part.The bad commit: /orders goes back to legacy.
>migrate the /orders and /invoices routes off the legacy module one step at a time, using a strangler fig, committing after each step
$ git log --oneline
4c7f84a order service as it is today
$ ls src
legacy.ts router.ts
$ cat src/router.ts
switch (req.path) { case '/orders': return handleOrders(req); // legacy case '/customers': return handleCustomers(req); // legacy case '/invoices': return handleInvoices(req); // legacy }
Three routes, one old module answering all of them.
2 · Your agent
Your agent asks MM3 how.
You do nothing. Your agent sends your sentence to MM3 and a recipe comes back. The recipe has three parts: the router that every request goes through, the legacy module that only ever shrinks, and the new module that takes routes over one at a time.
The rule that matters here is about direction: a route that has moved to the new module never goes back. That rule, and the others in the recipe, are our reading of the idea, and the recipe says so itself. The only sourced facts are Fowler’s and Microsoft’s definitions.
The honest wrinkle. For this long sentence MM3’s first suggestion was a different recipe (idempotent handlers), picked because the sentence contains words like “the” and “one”. The right one was third in the list. The agent saw that the sentence names a strangler fig and took that one, and left a note for us.
$mm3 apply-pattern “migrate the /orders and /invoices routes … using a strangler fig…”
1. Pattern: code.pattern.idempotent-handler (…), resolved from the request (keyword match: the, the, one, a, a). Other candidates: comp.control.asvs…, arch.pattern.strangler-fig, sec.pattern.input-validation; use the first unless the user named another.
agent: the user named a strangler fig → arch.pattern.strangler-fig, language typescript (from package.json)
[...]
SOURCES https://martinfowler.com/bliki/StranglerFigApplication.html (Martin Fowler, as of 2024-08-22) · https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig (Microsoft Learn)
In plain words, the recipe has three parts:
The routerThe one place every request goes through. It alone decides, path by path, who answers.
The legacy moduleThe old code. It loses a handler when its path moves, and never gains one.
The new moduleThe replacement. It takes paths over one at a time and never calls the legacy code.
The rule Over the history, the paths owned by the new module only grow. (R3, our reading)
3 · Your agent
It migrates in two steps, one commit each.
Still nothing for you to do. The agent writes the new module and moves /orders: the router now sends that path to the new module and the old handler is deleted from legacy in the same commit. Then it does the same for /invoices. /customers stays where it is, because you did not ask to move it.
The recipe says to add no tests, so there are none. We ran each commit by hand to see what answers each path (Node 22, no build): the output is in the panel.
git log --oneline
88461e1 migrate /invoices to the new component (strangler fig step 2)
2dd11ea migrate /orders to the new component (strangler fig step 1)
4c7f84a order service as it is today
files the agent wrote
src/modern.tsthe new module
src/router.tsthe router, edited each step
src/legacy.tsa handler deleted each step
bind.jsona note: which file plays which part
$ route({ path: … }) at step 2
/orders → modern orders /customers → legacy customers /invoices → modern invoices /nope → error: no route: /nope
4 · MM3
MM3 checks where it ended up.
You say yes to one paid check (we said yes, once). The result, run MM3-0392: pass.
The check is a fixed list of questions: nine yes/no, one overall goal, two judgement calls. One of them is a count: “how many paths does the legacy file still handle?” It passes on none or one. Here it says one (/customers), sure to 0.99.
The questions are locked with a fingerprint, so nobody can reword them to make a result come out better. This check cost about $0.00004 (estimated from tokens).
PASSrun MM3-0392 · overall goal 0.90
The router and the two modules
0.99
Q3. Does the router send at least one request path to the new component?
0.96
Q2. Does every path the router handles go to exactly one component?
0.05
Q7. Does the new component import or call anything from the legacy one, other than a request type? (healthy answer: no)
1
Count. How many request paths does the legacy file still handle? One, sure to 0.99. (passes on none or one)
All nine yes/no questions are healthy; the least sure of them is 0.90.
5 · Over time
Then it replays the same questions between commits.
A check at one commit tells you where you are, not which way you are going. So MM3 can replay the same questions at two commits and say what changed: fixed, still the same, or regressed. We compared the start with step 1, then step 1 with step 2.
Step 1 flips exactly what we predicted: the router now sends something to the new module, and no longer sends everything to legacy. The overall verdict is still “fail” because the count bar means finished (none or one left), and two are left. Step 2 gets there, and since every answer was already known, it cost nothing.
Startbefore any move
/orderslegacy
/customerslegacy
/invoiceslegacy
legacy still holds 3+
Step 1/orders moves
/ordersnew
/customerslegacy
/invoiceslegacy
legacy still holds 2
Step 2/invoices moves
/ordersnew
/customerslegacy
/invoicesnew
legacy still holds 1
Latera commit called “update router”
/orderslegacy
/customerslegacy
/invoicesnew
legacy still holds 2 (off: add it with the switch)
$mm3 replay — parent MM3-0392, start → step 1
MM3-0394 gate: fail(not finished: two paths left)
routing fail → pass fixed [1, 3]
shrinks fail → pass fixed [4]
expected: fixed [routing, shrinks] as predicted
legacy still holds 3 → 2
------
$ mm3 replay — step 1 → step 2
MM3-0395 gate: pass(free: every answer reused)
legacy-paths fail → pass fixed [10]
regressed: none legacy still holds 2 → 1
6 · Try it
Is it just saying yes? Add the commit that slides back.
Walk the history with the four buttons first. Then add one more commit, called only “update router”, on its own branch: it sends /orders back to the legacy module and restores the old handler. No comment, no branch name and no message says what it does. Flip the switch to add it.
Here is the twist. We expected the yes/no questions to catch it. They did not: all nine still pass. Look at that commit on its own: one path on the new module, two on legacy. That is exactly what step 1 looked like. Nothing is wrong in a snapshot; it is wrong only next to the step before. The one thing that moved is the count of paths legacy still handles: one, then two. MM3 flags it as regressed.
The good history: the router sends moved paths to the new module and the rest to legacy.The bad commit: /orders goes back to legacy. Nothing in the picture looks wrong on its own; it is wrong next to the step before.
Startbefore any move
/orderslegacy
/customerslegacy
/invoiceslegacy
legacy still holds 3+
Step 1/orders moves
/ordersnew
/customerslegacy
/invoiceslegacy
legacy still holds 2
Step 2/invoices moves
/ordersnew
/customerslegacy
/invoicesnew
legacy still holds 1
Latera commit called “update router”
/orderslegacy
/customerslegacy
/invoicesnew
legacy still holds 2 (off: add it with the switch)
One card per commit: who answers each path, and how many paths legacy still handles.
off: the history the agent made
Without JavaScript, here are the numbers in words. The bad commit changes router.ts so that /orders calls handleOrders from the legacy module again, and puts that handler back in legacy.ts. Result: MM3-0396, fail, goal 0.74. Routing, shrinks and direction all still pass (nine of nine yes/no questions healthy). The count of paths legacy still handles goes from one to two, sure to 0.99 both times.
// Every request comes in here.import type { Req } from './legacy.ts';import { handleCustomers } from './legacy.ts';import { handleOrders, handleInvoices } from './modern.ts';export function route(req: Req): string { switch (req.path) { case '/orders': return handleOrders(req); case '/customers': return handleCustomers(req); case '/invoices': return handleInvoices(req); default: throw new Error('no route: ' + req.path); }}
0.99
Q3. Does the router send at least one path to the new component?
0.10
Q5. Does legacy still hold a handler for a path the router sends to the new component? (healthy answer: no)
1
Count. How many paths does legacy still handle? one (sure to 0.99)(passes on none or one)
9 of 9 yes/no questions healthy.
A score is how sure the checker is that the answer is “yes”. The marked row is the count: how many paths legacy still handles, and how sure the checker is of it.
7 · The result
What it comes to.
One sentence from you. A recipe with a named source. Two commits by the agent. A verdict you can read for the end, a comparison you can read for each step, and a flag on the one commit that went the wrong way, even though a snapshot of it looked like step 1.
1 sentence from you
2 commits by the agent, one per step
9 of 9 yes/no questions still passed on the commit that slid back
1 count flipped: one → two (that was the whole catch)
$0.00017 for the whole history (estimated from tokens)
------
MM3-0392 pass · MM3-0395 pass(step 2, free)
MM3-0396 fail(the commit that slid back)
8 · A real app
What the agent built, at the end
Three small files, nothing else. The router is the only file that decides who answers. The new module imports only the request type from legacy, never a handler, and legacy imports nothing from the new module.
The router src/router.ts
// Every request comes in here.
import type { Req } from './legacy.ts';
import { handleCustomers } from './legacy.ts';
import { handleOrders, handleInvoices } from './modern.ts';
export function route(req: Req): string {
switch (req.path) {
case '/orders': return handleOrders(req);
case '/customers': return handleCustomers(req);
case '/invoices': return handleInvoices(req);
default: throw new Error('no route: ' + req.path);
}
}
The old module src/legacy.ts
// The old order system. It answers the request paths not yet moved.
export type Req = { path: string; body?: string };
export function handleCustomers(req: Req): string { return `legacy customers: ${req.body ?? ''}`; }
The new module src/modern.ts
// The new order system. It takes over request paths one at a time.
import type { Req } from './legacy.ts';
export function handleOrders(req: Req): string { return `modern orders: ${req.body ?? ''}`; }
export function handleInvoices(req: Req): string { return `modern invoices: ${req.body ?? ''}`; }
Run it at each commit Node 22, no build
step 1 (/orders moved)
/orders -> modern orders
/customers -> legacy customers
/invoices -> legacy invoices
step 2 (/invoices moved)
/orders -> modern orders
/customers -> legacy customers
/invoices -> modern invoices
later commit, “update router”
/orders -> legacy orders
/customers -> legacy customers
/invoices -> modern invoices
The app has no test suite (the recipe says to add none). The output above is what we saw running each commit’s route() by hand. The later commit still runs without errors: it is a perfectly working router that has simply gone backwards.
9 · Passed?
What MM3 said, step by step
What we asked
Passed?
Run
The finished migration (/orders and /invoices moved) is accepted
✅ PASS good code accepted · goal 0.90
MM3-0392
Step 1: the takeover is seen (routing and shrinks flip as predicted)
⚠️ right step, not finished yet · goal 0.89
MM3-0394
Step 2: nothing slid back, and now it is finished
✅ PASS good code accepted · goal 0.90
MM3-0395
A later commit sends /orders back to legacy
❌ FAIL slide back caught · goal 0.74, by the count only
MM3-0396
The one commit that went the wrong way was caught. The nine yes/no questions alone would have passed it.
Why it matters
An agent can finish every step of a migration and still leave you worse off than the step before, with every snapshot looking fine. A direction is something a single check cannot state. Comparing two commits can, and it costs a fraction of a cent.
Reality check
What we tested
One sentence to an agent; its two-step migration of a small TypeScript order service; a check on the end result; replays between each pair of commits; and one more commit, on its own branch, that sends a route back.
What should work
Any migration with a router in front of an old and a new part, where each step is a commit: the end state is checked once, and each step is compared with the one before.
What we learned the hard way
The slide was caught only by the count of paths legacy still handles (one, then two, MM3-0396). All nine yes/no questions still passed, because a snapshot of that commit looks like a normal mid-migration step. And the count’s bar means “finished”, so a perfectly good step 1 also reads “fail” (MM3-0394). Read the direction, not the gate.
The recipe does not tell the agent how to start its history. The agent’s first commit already moved a route, so the starting commit had no new-module file and MM3 could not compare it with step 1; we added an empty one ourselves. And the long sentence matched a different recipe first, so the agent had to spot the right one in the list.
Not yet
The engine has no history mode: the parent run and each replay were driven by hand, one command each. One language, one app, one bad history. Replay shows gates and counts, not scores per question, and cannot yet state “only grows” as a rule.
Receipts
Runs: parent MM3-0392 (end state, pass); replays MM3-0394 (start to step 1), MM3-0395 (step 1 to step 2, all answers reused, free), MM3-0396 (step 2 to the “update router” commit, fail). Four live calls in all: one for the parent, two for MM3-0394 (it had two new states to read), one for MM3-0396.
Cost: about $0.00017 for the whole history, summed from the runs’ recorded cost, estimated from tokens (no live pricing reported).
The bad commit sits on a side branch with a neutral name and the message “update router”. It changes two files (the router sends /orders to legacy, legacy gets its old handler back). We wrote it by hand.
We added the one starting commit that holds an empty new-module file, so the first pair could be replayed. Everything after it is the agent’s own commits. The starting code itself needs import type to run under Node’s type stripping; the agent’s first commit added it.
Replay reads whole files at each commit. We predicted “routing and shrinks fixed” for step 1 and no flips for the other two; the bad commit flipped the count, which we did not predict. The question set was locked before this run; its dry run noted that one question (Q2) reads as two joined, and we left it as it was.
One run per cell, so there is no measure of run-to-run noise. TypeScript only; the commits were run by hand with Node 22 type stripping, not built or type-checked.