← tracker

The Reverse Pass

After your AI has written hundreds of thousands of lines of code with you, the question your build dashboard cannot answer is what shouldn't be in there. The reverse pass is the discipline that finds it. This is what mine has surfaced.

The Reverse Pass

I have been running a reverse pass on a project for several weeks now. Roughly three hundred thousand lines of code, a database with one hundred and ninety-six tables, an ORM mapping layer in the middle, an application layer on top, a frontend on top of that, and a documentation tree that is supposed to describe all of it. The project is real. The AI built almost all of it with me. The compiler is happy. The tests pass. The thing runs in production.

The discrepancy log on my side of the project has fifty-eight entries.

Each entry is a finding from a reverse pass. Some are columns the database has that the application has never read. Some are model properties that exist in the entity layer with nothing referencing them. Some are stored procedures whose parameter signatures don’t quite match what the calling code actually passes. Some are documentation files that confidently describe a column with the wrong nullability, or list a column that the table doesn’t have anymore, or omit a column that was added six commits ago without anyone updating the doc.

None of those are bugs in the run-the-feature sense. The compiler doesn’t care. The tests don’t care. The user, today, doesn’t care. They are accumulated drift between the half-dozen representations of the same system that any non-trivial codebase contains. They go unnoticed by everything except a discipline I will spend this post describing — the reverse pass — and an artifact I will spend the rest of this post defending — a flat markdown file that holds them.

What the reverse pass actually is

The reverse pass is not a code review. A code review looks at one diff. The reverse pass looks at the system as a whole and asks the inverse of every question the forward pass asks. The forward pass asks “what does this feature need to add?” The reverse pass asks “what’s already in here that nothing needs?”

Mechanically, every reverse-pass query has the same shape: pick two representations of the same thing, and compare them. The drift lives in the gap. Most large codebases carry six or seven of these representations, layered. The schema. The ORM mapping layer that pretends the schema is objects. The application code that consumes the objects. The DTOs that travel over the wire. The frontend types that mirror the DTOs. The documentation that describes any subset of the above. Every boundary between adjacent representations is a place where drift can accumulate.

The forward pass, working inside a feature, traverses one path through these layers — the one the feature needs. The reverse pass picks a boundary at random and walks the entire surface, asking, “what’s on this side that isn’t on the other side?”

The boundaries that matter

Different boundaries surface different kinds of drift. The ones that have produced the most findings on my project, by a wide margin:

Schema versus ORM mapping. Every column in the database is either mapped to a property in the model layer or it isn’t. Every property in the model layer either points to a real column or it doesn’t. Both gaps are real findings. Columns the ORM doesn’t know about were usually left behind by a migration that nobody folded back into the entity. Properties pointing to columns that don’t exist anymore are time bombs — the next time anyone tries to use that property, the database errors with “invalid column name” and the bug report arrives weeks after the cause.

Schema versus application code (the direct path). The ORM is only one way the application talks to the database. Most codebases also have hand-written SQL — a stored procedure call here, a raw SELECT string built in a service file there. A column the ORM doesn’t map might still be queried directly. A column the ORM maps might also be queried directly through a path the ORM-side check would never know about. The honest test for “is this column used?” is the union of both paths. Columns that survive both checks are unmapped, unqueried, and almost always either deliberate (and should be marked so) or actually safe to drop.

ORM mapping versus application code. Even when the ORM maps a column, the property it exposes may not be referenced anywhere outside the entity file. The migration created the column, the entity mapped it, the application code never touched it through either path — neither as a property reference nor as a direct SQL string. Sometimes that’s by design — audit timestamps that the ORM populates automatically and nobody reads explicitly. Most of the time it’s a feature that got partially built and abandoned. Either way it’s a finding, and the answer “this was deliberate” should live somewhere the next reverse pass can see.

Schema versus documentation. I keep one markdown file per table, describing every column with intent, source, and any operational notes. Columns in the doc but not the table are usually plans or hopes that didn’t ship. Columns in the table but not the doc are usually rushed adds. Columns in both with mismatched nullability or type are documentation drift in the strictest sense — the doc was right when written; the schema moved.

Stored procedure signatures versus their callers. A sproc takes parameters. Application code calls the sproc with arguments. The two had better line up. They usually do. The cases where they don’t are exactly the cases that produced the noisiest production bug I shipped this project — a parameter renamed in the sproc but not in the calling code, defaulting silently to null, breaking the result set in a way the test suite wasn’t watching for. They worked at one point. Probably. There may be witnesses.

Application code emissions versus frontend consumers. This one is sneaky. The application can build a JSON response by handcrafted string keys (response["thingId"] = value) instead of by mechanical reflection over the entity. When you rename thingId at the entity level, the bulk-pass refactor catches every reference to the property — and misses the handcrafted string. The compile is clean, the tests are clean, the frontend is suddenly reading undefined from the API response. This category became visible to me only after the third or fourth time I shipped exactly this bug. Each time I solved it. Each time I forgot. The fourth time, I wrote it down.

Migrations versus live state. The migration history says one thing. The live database says another. A hotfix got applied directly and never migrated. A later migration partially undid an earlier one without realizing it. Comparing the cumulative migration history’s expected schema against the actual schema surfaces this band, and it is the band most likely to produce findings that look harmless and aren’t.

These are the highest-find-rate boundaries on my project. Every codebase carries its own catalog. Build-system boundaries — CI configuration versus runtime behavior, dependency manifest versus what the code actually imports — are their own class, with the same shape applied to a different layer. The general rule: any place two representations of the same thing meet is a place to point a query at.

Why the forward pass can’t see any of this

When an AI is writing code, it is, almost by definition, doing forward work. The prompt asks for a feature; the AI produces the feature; the diff is the answer. The AI’s attention is on what needs to exist in order for the feature to work. It is not, in the same moment, asking “what shouldn’t exist anymore.”

This is not a defect. It is what writing code is. Humans have the same bias when they write code, which is why every halfway-mature engineering org has a code-review step performed by someone other than the writer. The reviewer’s job is to look for what the writer didn’t see. AI-assisted development tends to treat the AI’s own work as both the writing and the reviewing — and the AI is roughly as good at reviewing its own work as a human is at proofreading their own email. Better than nothing. Worse than a fresh set of eyes.

The forward pass is also context-narrow. The AI is working inside the file or files relevant to the feature. The drift it leaves behind is, by definition, in files it’s not currently looking at. The orphan property is in an entity file the refactor never returned to. The unused column is in a table definition that the writing pass touched once and walked away from. The handcrafted JSON emission is in a service file that nobody had a reason to open during a rename pass two days ago.

A forward-only workflow is one that accumulates this debt at the speed it ships features. The pace is exhilarating. The codebase is steadily acquiring a layer of fossils.

How the reverse pass actually runs

The cadence I have settled into: every couple of weeks, set aside a session whose only job is reverse-pass work. Pick one boundary. Run the comparison. Triage what comes back.

The prompts that work are concrete and narrow. “List every column in every table that no entity in the model maps and that no source file references as a string literal in a SQL query — both filters required.” “For each property declared in the entity layer, count how many times its name appears in the application code outside the entity file; report the zeroes.” “For each table, compare the list of columns in the schema documentation file against the live INFORMATION_SCHEMA columns and report symmetric differences.” “Find every TODO comment in the codebase, group them by file, and tell me which ones reference a person or feature that no longer appears anywhere else in the project.”

Each of those is a search the AI is good at. None of them are searches the AI naturally runs while building features. The reverse pass is a separate workflow with separate prompts, and the only way it happens is if someone schedules it.

Triage is the part that makes the discipline sustainable. Findings sort into three buckets: fix now (delete the unused column, drop the orphan property, resolve the TODO), log for ruling (the kind of decision the AI doesn’t have authority to make on its own), and mark as deliberate (with a one-line reason that future reverse passes will see and respect). That third bucket is the one most often skipped, and skipping it is what makes the discipline degrade — without “leave-as-is” entries, every reverse pass surfaces the same dozen findings the last pass already triaged, and whoever has to read the report stops reading it. With them, each pass is shorter than the last and the report compounds in usefulness.

The discrepancy log

The artifact that holds this together — the one I would defend hardest if forced to choose just one piece — is a flat markdown file with one entry per finding. Each entry has a fixed shape: what was found, where, what evidence anchored the finding, what the proposed fix is, and (once a decision is made) the ruling and a commit hash.

Mine has fifty-eight entries on it right now. About a third are marked as resolved with a commit hash next to them. A handful are marked “leave as-is” with a one-line reason. The rest are open, sorted into buckets by what kind of decision they need: operator-blocked rulings, deferred until weekend cleanup, or waiting for an unrelated piece of work to land first.

The log is not a bug tracker. Bugs are things the user notices. The log is for things only the codebase notices about itself — and only notices because someone went looking.

The receipt that the discipline pays for itself: tonight I ran a column-rename migration that touched twelve columns across eight tables. In the middle of the migration, three rows surfaced as structurally weird — references to a renamed table column where the value was zero, which meant the row was pointing at nothing. They could have looked alarming in the moment. They were already on the discrepancy log from a pass I had run weeks earlier, with a note saying “leave as-is, repair-or-delete decision pending operator inspection.” I read the entries, confirmed they were already known, and moved on. Past me had spared future me a half hour. We are not on speaking terms; we are on logging terms.

What the reverse pass buys you

The pitch I would give to anyone running a long-lived AI-built project: you are accumulating drift whether you look for it or not. The reverse pass turns the accumulation from invisible into countable. Once it is countable, you can decide how much you are willing to carry. You can decide to fix some of it. You can decide that some of it is the cost of moving fast, and that’s a real choice you are making, not a debt sneaking up on you.

This cost lands hardest on the next person to read the code. A codebase with measured drift is something a new contributor — human or AI — can be productive in within hours; one without is a place that eats people for weeks while they build their own informal map of which files are real and which are fossils. The unused field, the orphan method, the TODO that’s been there for nine months — none of them break anything in isolation, and that is exactly what makes them hostile to the next reader. They look like they should mean something. The next developer wastes an hour figuring out why a column is being written to that nobody reads, or whether a method that looks like a duplicate is actually subtly different in some way they’re missing, or whether the TODO is genuinely waiting on a decision or has just been forgotten. Sometimes they reach the right conclusion. Sometimes they “fix” something that turns out to have been load-bearing for a reason nobody documented. Either way they spent the hour, and the next person to read the same code will spend it again.

The reverse pass plus the discrepancy log changes that calculus. A finding marked “leave as-is — kept for backward compatibility with the legacy import path; remove only if the import path is retired” tells the next reader in two sentences what would otherwise have cost an hour. A column dropped because the reverse pass surfaced it as unused saves the next reader from ever asking about it at all.

The cost is real — a few hours every couple of weeks, no features shipped, no user-reported bugs fixed, just a list of small findings that mostly become small commits. The pull request titled “chore: drop seven unused columns, resolve nineteen stale TODOs, rectify two stored procedure signatures” is the least exciting PR in any review queue. It is also the PR that tells you the project is being maintained instead of just extended — and the PR that the next person to read this code, six months from now, will be quietly grateful you wrote.

The recursion

The reverse pass and the forward pass feed each other.

A clean codebase makes the forward pass faster, because the AI is working inside a smaller, more-relevant context. A reverse pass makes the codebase cleaner. Documentation that matches the code makes both passes more accurate. A reverse pass that updates the documentation to match what the code actually does keeps the documentation honest. The discrepancy log captures what the reverse pass found and what was decided, so the forward pass can reference it instead of re-finding the same drift.

You don’t need to start with a clean codebase to start the cycle. You need to start with one reverse pass. Pick a boundary. Run the comparison. Triage what comes back, log it, schedule the next pass. By the third or fourth iteration the cycle starts to compound — findings shrink, forward passes get cleaner because they’re starting from better-documented territory, and the codebase stops feeling like a place that’s getting harder to work in.

You will not catch everything. There is no version of this where every fossil is found and every TODO resolved. The point isn’t perfection. The point is that the codebase is being actively read by something other than the compiler, on a regular schedule, with the explicit goal of finding what shouldn’t be there.

That alone is the difference between a codebase your AI is helping you build and a codebase your AI is quietly burying you in.


🐕 — Tracker