The most expensive line in an agent-generated pull request is rarely the wrong one. It is the one you cannot take back.
A wrong function body costs you a revert. A dropped column costs you a restore window, a status page, and a postmortem. That asymmetry should drive the review order, not the diff order.
This post describes a review pass you run before reading logic: classify every changed path by rollback cost, then decide what to split, what to test, and what to reject outright. The artifact is a git-only classifier plus a decision table. No model required for the classification step, which is the point.
The review question that survives a bad diff
Most review advice optimizes for spotting wrong logic. That is the part agents are usually good at — they produce plausible, readable code that passes a skim.
The failures that actually hurt are structural. A migration that drops a column. A renamed module that breaks an import two services away. A lockfile bump nobody read. A test marked skip to make CI green. Every one of those survives a logic read and fails in production.
So flip the order:
- Classify each changed path by rollback cost.
- Split anything irreversible into its own PR.
- Test the rollback, not just the feature.
- Only then read the logic.
Classify before you read
The four buckets
- Irreversible — the change cannot be undone by
git revert. Schema migrations,DROP/TRUNCATE, renames that break consumers, deletions of files still referenced elsewhere, published artifacts, secret rotations. - Mutating — revertable in git, but the revert is not a no-op. Lockfiles, dependency pins, Dockerfiles, CI workflows, build config,
.envshape. - Ordinary — application code with normal blast radius. Standard review applies.
- Additive — new tests, new docs, new files nothing imports yet. Safe to merge ahead of the code that uses them.
A PR that mixes buckets is not one PR. That sentence alone resolves a large share of agent PR review disputes, because agents will happily bundle a refactor with a migration if nobody stops them.
The classifier
The script below is a git-only classifier. As written here it has only been exercised against synthetic repositories — treat it as a starting point you validate, not a tool with a track record.
#!/usr/bin/env python3 """irreversibility_audit.py — rank PR hunks by rollback cost. Usage: python irreversibility_audit.py --base origin/main Requires: git, Python 3.9+. No network access. Exit code 1 if any IRREVERSIBLE path is present. """ import argparse, re, subprocess, sys BUCKETS = [ ("IRREVERSIBLE", [r"(^|/)migrations?/", r"(^|/)alembic/versions/", r"\.sql$", r"(^|/)schema\.prisma$"]), ("MUTATING", [r"(^|/)(Dockerfile|docker-compose\.ya?ml)$", r"(^|/)\.github/workflows/", r"(^|/)(package-lock\.json|poetry\.lock|uv\.lock|go\.sum)$", r"(^|/)(pyproject\.toml|package\.json|go\.mod)$"]), ("ADDITIVE", [r"(^|/)tests?/", r"(^|/)docs?/", r"\.md$"]), ] RISKY = [ ("drop-or-truncate", r"\b(DROP\s+(TABLE|COLUMN)|TRUNCATE)\b"), ("not-null-no-default", r"NOT\s+NULL(?![^\n]*DEFAULT)"), ("skip-or-xfail", r"(@pytest\.mark\.(skip|xfail)|\.skip\(|xit\()"), ("swallowed-error", r"except\s*:\s*$|except\s+Exception\s*:\s*pass"), ("destructive-shell", r"(rm\s+-rf|--force\b|shutil\.rmtree)"), ("permissive-mode", r"chmod\s+0?777|mode=0o?777"), ] ORDER = {"IRREVERSIBLE": 0, "MUTATING": 1, "ORDINARY": 2, "ADDITIVE": 3} def git(*args): return subprocess.run(("git",) + args, capture_output=True, text=True, check=True).stdout def bucket_for(path): for name, pats in BUCKETS: if any(re.search(p, path) for p in pats): return name return "ORDINARY" def main(): ap = argparse.ArgumentParser() ap.add_argument("--base", default="origin/main") base = ap.parse_args().base rows = [] for line in git("diff", "--name-status", f"{base}...HEAD").splitlines(): status, *paths = line.split("\t") path = paths[-1] # renames list old + new; audit the destination findings = set() if set(status) & {"D", "R"}: findings.add("removes-or-renames-path") diff = git("diff", "-U0", f"{base}...HEAD", "--", path) added = [l for l in diff.splitlines() if l.startswith("+") and not l.startswith("+++")] for label, pat in RISKY: if any(re.search(pat, l) for l in added): findings.add(label) rows.append((bucket_for(path), status, path, sorted(findings))) for bucket, status, path, findings in sorted(rows, key=lambda r: ORDER[r[0]]): tail = f" <- {', '.join(findings)}" if findings else "" print(f"{bucket:<13} {status:<4} {path}{tail}") if any(r[0] == "IRREVERSIBLE" for r in rows): print("\nVERDICT: split before merge — irreversible paths present.") return 1 return 0 if __name__ == "__main__": sys.exit(main()) Two honest caveats about the code. It shells out once per file, so it is fine on a 40-file PR and wasteful on a 4000-file one; batch the git diff call before you scale it. And the regex list is a starting set, not a taxonomy — add the patterns your own incidents produced.
A synthetic worked example
Labeled synthetic output, from a fixture repo — not a real incident:
IRREVERSIBLE A db/migrations/0042_drop_legacy_flag.sql <- drop-or-truncate IRREVERSIBLE M db/migrations/0043_add_tenant_id.sql <- not-null-no-default MUTATING M poetry.lock MUTATING M .github/workflows/test.yml ORDINARY M api/handlers/report.py ORDINARY M tests/test_report.py <- skip-or-xfail ADDITIVE A docs/report-export.md VERDICT: split before merge — irreversible paths present. Read that output the way a reviewer should. The agent bundled a column drop, an ADD COLUMN ... NOT NULL without a default, a dependency bump, a CI edit, and a newly skipped test into one PR. Nothing about api/handlers/report.py is the problem. The problem is that four of those seven paths have different rollback semantics, and one of them cannot be rolled back at all.
What IRREVERSIBLE demands
- Split it out. It goes in its own PR, merged first, deployed on its own schedule.
- Expand, then contract. Add the new nullable column, backfill, deploy code that writes both, then drop the old one in a later PR. Two migrations are cheaper than one outage.
- Test the backward path. Restore a prod-shaped snapshot, run the migration forward, run the previous release against the new schema, then roll the app back without rolling the schema back.
- Accept that revert is a forward fix. Once a
DROPships,git revertis not a rollback plan. Say that in the PR description so nobody discovers it at 2am.
Decision table
| Bucket | Merge rule | Test before merge | Actual rollback |
|---|---|---|---|
| IRREVERSIBLE | Own PR, merged first, deployed separately | Forward + backward on a prod-shaped snapshot | Forward fix; revert is a new migration |
| MUTATING | Merge after schema work, version pinned | Clean-cache build, CI on oldest supported runner | git revert, then confirm the lockfile resolves |
| ORDINARY | Normal review | Targeted unit test + one integration path | git revert |
| ADDITIVE | May merge ahead of its consumer | None beyond lint | Delete the files |
Where a free model pass earns its place
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode offers free model access together with a free server option, per the operator's description — check the current terms yourself, since free-tier limits are the kind of thing that changes quietly.
The useful role for a model here is advisory triage of the classifier's findings, not a safety verdict. The classification stays in git and regex, where it is deterministic and inspectable. The model gets one narrow question:
Below is a bucket listing and the added lines it flagged. 1. Which single finding is most likely a false positive, and why? 2. Which risky change in these files is NOT in the list? Do not say whether the PR is safe. Do not summarize the diff. That prompt shape matters. Ask a model "is this safe?" and you get a confident paragraph that reads like coverage. Ask it "what did the classifier miss?" and you get a claim you can check in thirty seconds.
The free server option addresses a different problem: the audit is only useful if it runs on every PR, including the ones opened while your laptop is closed. A hosted runner that executes the script on pull_request targets beats a local habit, because local habits decay.
Limitations, and who should skip this
- You have no restorable snapshot. Then IRREVERSIBLE classification is theater — you had no rollback before this process either. Fix the snapshot first.
- Small single-maintainer repos with no migrations. Four buckets for a three-file diff is overhead. Use the risky-line list only.
- ORM- or Terraform-generated DDL. Regex on added lines will miss schema changes that appear at plan or runtime. Read the generated plan.
- You cannot read the diff anyway. This pass orders your attention; it does not replace reading code. A classified-clean PR can still be wrong.
- Untrusted forks. Do not run a bot that checks out and executes PR code on your runner. This script only calls
git diff, which is why it is safe to run early — keep it that way.
Close
Run the classifier on your next agent PR before you read the logic. If a DROP shows up in a PR titled "refactor report handler," you have learned more in ten seconds than in ten minutes of reading conditionals.
If you try the advisory pass on MonkeyCode's free tier, keep the findings advisory and your tests authoritative — the model argues, the rollback plan decides.


