Brownfield

Everything so far has assumed a clean start. Greenfield is the easy case: sketch the new thing, have an agent vibe-code a first prototype, and work your way down into an increasingly governed environment, avoiding the mistakes your last project taught you. But most readers are not on a clean start. Most are staring at a legacy codebase — started before agents existed, and since made worse by an agent that added a hundred thousand lines nobody can read. This chapter is the recipe for getting that codebase from spaghetti to a real model-based engineering environment, step by step.

High deployment velocity is not new, and neither is the risk that rides with it. The industry learned years ago that shipping to production hundreds of times a day is possible and that it is unforgiving when the discipline behind it slips — high velocity, high risk, just ask Amazon, which was deploying to production every 11.6 seconds a decade before an agent could write the code for you.

The starting point and the destination

Name both ends before you move. The starting point is familiar: some spaghetti, thin documentation, and invariants that live only in one person's head — the person you hope never retires. The destination is specific. You want the models needed to express every view of the system that bears on a quality you care about — performance, correctness, security, privacy. You want code that reflects those models, so the map equals the territory: zoom in on the map and you see county lines, not Switzerland dropped in by surprise. And you want traceability for your quality case — the ability to make the argument automatically. For any property you claim, you should be able to point to the code that implements it, the static analysis that enforces it, the protocol you model-check it against, the tests that confirm it across the inputs you try, and the record of every change and why it was made. That is the output. With it, soft and hard governance can hold the whole thing in place and keep it current. There are three ways to get there, and a real migration uses all three.

Approach one: top-down, from a whiteboard

Start coarse and refine. Your first model can be as crude as "on this input, the test suite passes and we get the right output" — and you should be able to write that today. From there you ask what happens between input and output: insert a file, and out comes a score, a spreadsheet, whatever your program produces; insert a picture, and out come the cats and antelopes it found. Refine until the model captures the abstractions you need, and stop when you hit diminishing returns — the territory is the territory, and the map only has to be detailed enough to carry the assurance you are after.

You can seed this from a literal whiteboard: photograph the diagram, hand it to the agent, and have it produce a data-flow diagram in whatever representation you like. Then iterate against the code — "what nodes am I missing, what flows am I missing?" — and tighten the policies as the model sharpens: static assertions ("we never call exec"), then the invariants of each module (its preconditions, its post-conditions), then the properties preserved across module interactions. As you do this, every mismatch between model and code means one of exactly three things: the model is wrong and needs refining; the model is at the wrong level of abstraction for what you are checking, so you need a different model; or there is a genuine bug in the code — and the modeling just found it. Expect to find many. Shifting a legacy codebase from "the tests pass" to "these properties hold across all behaviors" surfaces defects by design. Some you will fix; some you will decide are working-as-intended and fix the model; some you will scope out by narrowing the model's assumed inputs. All three are legitimate moves.

Approach two: bottom-up, inducing the model from code

The second approach starts from the code and flies upward. Ask the agent to induce the model: "look at the codebase and help me develop the model inductively." Where the first approach starts from your coarse whiteboard understanding and refines down toward the code, this one starts on the ground and rises. Picture standing on a street and flying a drone up: the individual buildings blur, the street grid appears, then the highways, then the cities. As you climb, the levels of abstraction reveal themselves — and whether you came top-down or bottom-up, you are trying to meet in the middle, at the level that lets you enforce the properties you care about.

Which level that is depends entirely on the assurance you need, and it is worth being concrete about the trade. For low assurance, just run the tests — the coarsest model is "this input, that output, checked dynamically." For strong guarantees you need a refined model and stronger assertions; for the strongest, elaborate analyses over the control-flow graph and every function's inputs and outputs — which is expensive both to write and to run, because the checker must search every acre of the whole county. So begin by writing down what correct even means, because correctness is not one thing: it means one thing for security, another for semantics, another for privacy, and the definition tells you the abstraction level you need. Memory safety demands a fine-grained model that sees every access — and you will pay for it. A semantic property is often far cheaper.

Take an audit trail on a spreadsheet: "every modification is stamped." You do not model every function to enforce that. You just require that every code path which modifies the ledger calls the stamp routine on the way out — a property about the order of two calls in the control-flow graph, not about what either call does. That is a high level of abstraction and therefore cheap to enforce. The companion catalogue ships this exact pattern as a product sensor: a lint that checks every format-mutating routine is wired to its provenance stamp, so an unstamped mutation cannot ship. Proving the stamp routine itself is correct would need a much finer model — but if a human review or a test covers that, the coarse model is enough. Choosing the fitness function, then the abstraction that enforces it cheaply, is the new job, and it varies by codebase. It is yours to think through.

Approach three: lint, cover, then induce

Both approaches assume you can make sense of the code — and often you cannot, because it is a mess. Models read code the way humans do: they like comments that give rationale, names that reflect purpose, and shallow nesting, because deep nesting has to be held in working memory, and an agent pays for that context just as a human pays in confusion. So before you extract models from spaghetti, improve the code — and there is a loop for that, the same loop from Loops and Models. Define the inputs, the judgment, the output, and the metric. The metric here is a smell detector.

A smell is code that works but will break the moment it is touched, because no one can reason over it. You measure smells with cheap static analyses — lints (the dryer-lint kind, the stuff you pull out to make things tidy) — that parse the syntax tree and count the tells: a high density of bare ints and strings, deeply nested conditionals. Some rules are universal. No codebase should have a 2,000-line function, a module imported by every other file, a switch with 400 string cases, structs nested three anonymous levels deep, or functions returning unnamed many-element tuples. So run the lints, and have the agent fix them in a behavior-preserving way — which means you need tests. If you have none, start there: have the agent write tests for line or branch coverage, module by module, because coverage is what lets you refactor safely, using the tests and the lints together as your success metric.

Squash, zero, promote

Squash, zero, promote: how a lint is worn into a legacy tree one step at a time A new smell-detecting lint moves through three states, left to right. First it lands audit-only: it reports every finding but blocks no commit, so it never breaks an in-flight agent. Then a fix wave — an agent making behavior-preserving edits under test coverage — drains the finding count to zero; this is a loop that runs under the audit-only state until nothing is left. Only when the count reaches zero is the lint promoted to blocking: it now fails any commit that reintroduces the smell. A one-way barrier sits after the blocking state, marking that the class of smell can never return. Squash the findings, drive them to zero, then promote — and the smells that leave stay gone. new lint Audit-only reports, blocks nothing Findings → 0 a fix wave drains it Blocking fails any commit that regresses land at zero behavior-preserving fixes, under test coverage the smell can never return
How one lint is worn into a legacy tree. A new smell-detecting lint lands audit-only — it reports every finding but blocks no commit, so it never breaks an in-flight agent. A fix wave drains its findings to zero with behavior-preserving edits under test coverage. Only then is it promoted to blocking, where it fails any commit that reintroduces the smell. The barrier after the final state marks the payoff: the class of smell can never return.

Then comes the move that makes it stick: as each lint reaches zero, make it blocking. Now the success metric is "all tests pass and that lint still passes" — no more unnamed tuples, no more imports buried mid-function, ever again. This is the Alignment Thesis worn into a legacy tree one lint at a time: each promotion is a governance mechanism that bounds what any later agent can reintroduce. Order matters, and the companion repository holds to it deliberately — flipping a lint to blocking while findings remain would break every agent's commit at once, so the figure's sequence is the safe one: audit-only, drain to zero, then promote. The smells that leave never return. When you are done, you have cleaned-up spaghetti with enough shape that model induction has something to grip: classes and enums and named structs give the induction real structure to reason over, the structure that spaghetti hid. The catalogue's coverage-to-model mapping closes the loop from the other side — it projects your test coverage onto the model's nodes, so an untested invariant shows up as a visible gap rather than hiding inside a line-coverage percentage.

You will need all three approaches in combination. It is not magic; it is hard work and judgment. But applied together, and scaled to the quality of your starting point, they land you at the destination: the right models, encoding the right views, with the right correctness criteria, traceable to where each is enforced — statically, dynamically, or by a model check.

I owe you honesty about the evidence here. This is a solo field report, not a controlled study. I did not run the migration twice, once with governance and once without, and measure the delta — I had one codebase and one pass through it, so I cannot hand you a clean before-and-after number. Take what follows as lived experience, weighed as such.

How much governance is enough

Which raises the real question: how much governance is enough? There is no formula. There is a judgment, and it turns on two factors multiplied together — how much a failure costs you, times how often it happens. Cost is not only product risk. An agent that trips over a failure burns tokens working around it, and tokens are real dollars, so a cheap-looking failure that every agent stumbles into ten times a day is expensive in a way the incident log never shows. Multiply the two. High cost times high frequency earns a hard mechanism — a blocking lint, a gate, a typed seam that makes the mistake impossible. Low times low you leave to judgment; a mechanism there costs more attention to maintain than the failure ever costs you. The middle band is where taste lives, and where you will spend it. This is also when to stop: a mechanism guarding a failure that is both rare and cheap is the first brick in the teetering tower The Skills warns against.

How I know this

I know this because I lived it. I vibe-coded a couple hundred thousand lines of production code, and it worked — but I had imposed real control only on the parts I knew were sensitive, where I pushed hard for a strong architecture. The rest — the web layer, the front end — I hoped the agent would just get right. It did not, and those parts turned crufty. I told it to use Python; it did, without types. I told it to retrofit types; it turned on the type checker and made everything strings and ints, without building the type abstractions I actually wanted. The same thing happened porting JavaScript to TypeScript that same week — a hundred thousand lines of "typed" Python and a hundred and fifty thousand of TypeScript, each ported the cheapest way possible, because the agent had no model and did not know the names of things. It is probably better that it did not guess.

So I retrofitted the models onto the mess — moving the code toward a clean model-view-controller architecture, moving the web stack to reactive, loop-based structures friendly to auto-scaling. And I did it exactly the way this chapter describes. I turned on every lint from Ruff, Pylint, and ESLint — hundreds of commodity smell checks — and for several weeks the agents squashed them, making each one blocking as it went to zero so it could never come back. I knew the coarse architecture I wanted and said "pursue this model," but I had to get there iteratively: find the dense primitive regions, induce structure there, and inch toward the target.

A dense primitive region is a stretch of code doing many low-level operations by hand — raw library calls, strings and ints passed around untyped, tree-walking spelled out inline — with no abstraction that owns them. I found these the crude way, by reading for the clots: where the primitive calls piled up thickest, the missing model was loudest. Inducing structure means extracting a typed model that owns those primitives — one class the raw calls now route through — and rewriting the region to go through it. You do this region by region, not all at once, because each induced model gives the next region something to lean on, and the target architecture assembles out of the pieces. That is what the squash-to-zero does at the fine grain, too: each lint I drove to zero was one primitive smell given a name and a home, and the model-view-controller decomposition is just the same move at the coarse grain. After a while, it worked. Now I have the models, the architecture, the code that implements it, and enforcement holding all three together every time the code changes.

Contents
© James C. Davis, 2026–present