AI agent re-adds reverted code when a fresh session, with no memory of last month's decision, re-proposes it. revert_guard.py is an offline, keyless pre-commit gate: it reads your repo's own git revert history and blocks (exit 1) a diff that reintroduces a column the team already added and reverted, before the commit lands.
AI disclosure: I wrote
revert_guard.pywith an AI assistant and ran it myself, offline, before publishing. Every output block below is pasted from a real local run on Python 3.13.5 and git 2.50.0, standard library only, no network. I ran each scenario twice and confirmed the stdout was byte-for-byte identical; the tool also prints a sha256 digest of its own report so you can check. Thecard_token/ PCI-DSS story and the Selvedge fix are @masondelan's, reported on Dev.to; that is their case and their fix, not my measurement. My exit codes, hashes, and the2026-06-05revert are synthetic fixtures on a real git repo, each from a real run, and I keep them in their own paragraphs so the two never blur.
In short:
- A new agent session has no memory of last month's decision. It reads the current schema, sees no
card_tokencolumn, and helpfully proposes adding one. The reasoning that killed it the first time (a PCI-DSS scope call) is gone. The revert that killed it is still sitting ingit log. - The tool reads that revert history. For every commit whose message marks it as a revert, it extracts what the revert removed, then checks whether the agent's diff re-adds it. Match by entity (
users.card_token), not by file path, so a fresh migration number does not slip past. - The demo: the same proposed diff, a new migration adding
users.card_token. Point it atrepo_cleanand it exits 0 (SHIP). Point it atrepo_dirty, where that column was added and reverted, and it exits 1 (BLOCK) and printsreverted 2026-06-05 in c2ce7ed -- reason: "PCI-DSS scope". The only variable is the repo's revert history. - It is not about memory cost, not about permissions — the agent re-proposes what the team already reverted, because the reasoning died with the session. The revert did not die with it.
- Standard library only. Offline, keyless, read-only, zero network. It shells out to a local
git log/git showon read, never writes, never runs the agent. Exit 0 / 1 / 2 for a CI gate. Deterministic stdout with a self-hash. The tool and every fixture are in this post.
The code sticks around. The reasoning doesn't.
On July 6, an engineer posting as @masondelan wrote up an incident on Dev.to that I have not stopped thinking about. Their line for it: the code sticks around, the reasoning doesn't. A team had added a card_token column to their users table, then reverted it two days later because it pulled the table into PCI-DSS scope. About a month on, a fresh Claude Code session, working from the current schema with none of that history in context, planned the exact same column back in. Their fix was a runtime MCP server called Selvedge that answers prior-attempts users.card_token with something like "Prior attempt 28 days ago (reverted after 2 days)." Those numbers and that fix are theirs. I am borrowing the shape of the problem, not the measurement.
Here is the shape. The revert is not lost. It is a commit, in the log, with a message. Git is tracking the fact that the team said no. What git does not do is stop the next actor from proposing it again. A human reviewer might remember. A fresh agent session will not, and neither will a tired reviewer at 6pm looking at a diff that, on its own, looks completely reasonable. The information exists and nothing acts on it. That gap between "the repo knows" and "something enforces it" is the whole space this tool sits in.
So the tool turns the tracking into control at one specific moment: before the diff is committed. It does not need the agent's memory, a vector store, or a running service. It needs the history the team already keeps.
What revert_guard checks in your git revert history
Three verdicts, one rule, read off the repo's own reverts.
-
REINTRODUCES_REVERTED fires when a table-qualified entity in the diff, like
users.card_token, exactly equals one a revert commit removed. That is a BLOCK. It prints the revert's short hash, date, and the reason the commit message stated. -
NAME_MATCH_UNVERIFIED fires when the bare name matches but one side is unqualified, so the tool cannot confirm it is the same table's column. That is a WARN, and it is fail-closed by default: a human confirms. It is the honest third state, and it exists so the tool does not have to pretend a bare
card_tokenin a model file is definitely the revertedusers.card_token. - NO_REVERT_MATCH is a SHIP. Nothing the diff adds was ever reverted here.
The distinction the whole thing turns on: ALTER TABLE users ADD COLUMN card_token gives a qualified entity, because the table is right there on the line. A lone card_token = Column(...) in a model gives a bare one, because nothing on that line says which table. Two qualified entities have to match table and column to BLOCK. A bare one on either side can only ever reach WARN. Same column name, different confidence, different verdict.
Run it in sixty seconds
No keys, no network, no install past Python and git. Save the file, point it at a proposed diff and a repo, run one command. Here is the whole tool, one file, standard library only:
#!/usr/bin/env python3
"""
revert_guard.py -- an offline pre-commit gate that reads a repo's OWN git revert
history and blocks (exit 1) an AI agent's proposed diff that reintroduces a
column / symbol / flag the team already added and then REVERTED -- before the
diff is committed.
It takes a proposed change (a unified diff, or a JSON list of entities) plus a
`--repo` path. It shells out to a LOCAL `git log` / `git show` on READ only,
finds the commits whose message marks them as a revert, extracts the entities
those reverts removed, and intersects that set with the entities the proposed
diff adds. The match is by ENTITY (a column name, table-qualified when it can be:
`users.card_token`), never by file path -- so a brand-new migration file with a
different number is still caught.
REINTRODUCES_REVERTED -- a table-qualified entity in the diff (e.g.
users.card_token) exactly equals one a revert commit
removed. BLOCK. Prints the revert's short hash, date,
and stated reason.
NAME_MATCH_UNVERIFIED -- the bare name matches a reverted entity but one side
is unqualified, so it cannot be confirmed the SAME
table's column. WARN, fail-closed (a human verifies).
NO_REVERT_MATCH -- nothing the diff adds was ever reverted here. SHIP.
BAD_INPUT -- not a git repo, unreadable/empty diff, bad JSON.
The point the tool exists to make: take ONE proposed diff -- a new migration that
adds users.card_token -- and run it against two repos. On a repo that never
reverted that column it exits 0 (SHIP). On a repo where the same column was added
and reverted last month it exits 1 (BLOCK), and prints the prior revert. Same
diff, same agent; the only variable is whether the REPO remembers the revert.
This is not about the cost of agent memory and not about permissions -- the agent
re-proposes what the team already reverted, because the reasoning died with the
session, while the revert did not.
Offline. Keyless. Read-only. Zero network. Standard library only (subprocess for
`git` on read, sys, re, json, hashlib, argparse). It never writes, never commits,
never runs the agent, never touches the network, and reads the diff as text --
it is DATA, never executed. Output is byte-for-byte deterministic across runs on
the same repo; it prints absolute revert dates (not "N days ago") on purpose, so
the output does not change with the calendar, and ends with a sha256 digest of
its own report so two runs are provably identical.
It does NOT store memory or embed reasoning; it reads git history the team already
keeps. It does NOT decide who is allowed to change what. It does NOT understand
WHY: it matches names, not intent, so a column reverted for reason X and now
legitimately needed for reason Y is still flagged for a human to override. It only
sees reverts that are actually COMMITTED -- a revert done by force-push, squash,
or amend-out-of-history is a blind spot. It is as good as the team's git history
is honest.
Exit codes (usable as a pre-commit / CI gate):
0 SHIP (no proposed addition was previously reverted here)
1 BLOCK or WARN (both mean "do not auto-apply"; the reason-code says which).
WARN's exit is configurable via --warn-exit (default 1, fail-closed).
2 bad input: not a git repo, missing/unreadable/empty diff, unparseable JSON
-- fail-closed.
Usage:
python3 revert_guard.py <proposed.diff | entities.json | -> --repo <path>
python3 revert_guard.py proposed.diff --repo ./service
git diff --cached | python3 revert_guard.py - --repo .
"""
import argparse
import hashlib
import json
import re
import subprocess
import sys
# A commit is treated as a revert if its subject or body matches this. Covers a
# native `git revert` ("This reverts commit <hash>") and hand-written reverts.
DEFAULT_REVERT_PATTERN = (
r"(this reverts commit|\brevert(s|ed)?\b|\brolled back\b|\bbacked out\b)"
)
# SQL: `ALTER TABLE users ADD COLUMN card_token ...` -> ('users', 'card_token').
RE_ALTER_ADD = re.compile(
r"alter\s+table\s+[\"`']?(\w+)[\"`']?.*?\badd\s+column\s+"
r"(?:if\s+not\s+exists\s+)?[\"`']?(\w+)",
re.I,
)
# SQL: bare `ADD COLUMN card_token` with no table on the line -> ('card_token',).
RE_ADD_COLUMN = re.compile(
r"\badd\s+column\s+(?:if\s+not\s+exists\s+)?[\"`']?(\w+)", re.I
)
# Python model field: `card_token = Column(...)` / `x: Mapped[str] = mapped_column(`
RE_PY_COLUMN = re.compile(
r"^\s*(\w+)\s*(?::[^=]+)?=\s*(?:\w+\.)*(?:mapped_column|Column)\s*\("
)
def _bad(msg):
print("ERROR: " + msg)
raise SystemExit(2)
def _git(repo, args):
"""Run a read-only git command, return stdout. Never writes."""
try:
proc = subprocess.run(
["git", "-c", "core.quotepath=false", "-C", repo] + args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
text=True,
)
except OSError as exc: # git not installed
_bad("cannot run git: %s" % exc)
if proc.returncode != 0:
_bad("git %s failed in %s: %s"
% (" ".join(args), repo, proc.stderr.strip()))
return proc.stdout
def entities_from_line(text):
"""Extract 0..1 entity from a single added/removed diff line.
Returns a list of (name, qualifier_or_None, kind). Names/qualifiers are
lower-cased so `Users.Card_Token` and `users.card_token` normalize together.
"""
m = RE_ALTER_ADD.search(text)
if m:
return [(m.group(2).lower(), m.group(1).lower(), "column")]
m = RE_ADD_COLUMN.search(text)
if m:
return [(m.group(1).lower(), None, "column")]
m = RE_PY_COLUMN.match(text)
if m:
return [(m.group(1).lower(), None, "symbol")]
return []
def entities_from_patch(patch, sign):
"""Collect entities from the +/- content lines of a unified diff.
sign='+' reads added lines (a proposed change); sign='-' reads removed lines
(what a revert took out). File headers (+++/---) are skipped.
"""
out = []
for line in patch.splitlines():
if not line or line[0] != sign:
continue
if line.startswith("+++") or line.startswith("---"):
continue
out.extend(entities_from_line(line[1:]))
return out
def dedupe(entities):
"""One entity per name; keep the qualified form when both exist."""
by_name = {}
for name, qual, kind in entities:
cur = by_name.get(name)
if cur is None or (cur[1] is None and qual is not None):
by_name[name] = (name, qual, kind)
return [by_name[k] for k in sorted(by_name)]
def parse_reason(subject):
"""Prefer the trailing parenthetical as the human reason, else the subject."""
m = re.search(r"\(([^()]+)\)\s*$", subject)
return m.group(1).strip() if m else subject.strip()
def find_reverts(repo, revert_re, since):
"""Return a list of revert commits, most recent first, each with the
entities it removed. dict: hash, short, date, subject, reason, entities."""
fmt = "%H%x1f%h%x1f%ad%x1f%s%x1f%b%x1e"
args = ["log", "--no-color", "--date=short", "--format=" + fmt]
if since:
args.append("--since=" + since)
raw = _git(repo, args)
reverts = []
for record in raw.split("\x1e"):
record = record.strip("\n")
if not record:
continue
parts = record.split("\x1f")
if len(parts) < 5:
continue
full, short, date, subject, body = parts[0], parts[1], parts[2], parts[3], parts[4]
if not revert_re.search(subject + "\n" + body):
continue
patch = _git(repo, ["show", "--no-color", "--format=", "-U0", full])
removed = dedupe(entities_from_patch(patch, "-"))
if not removed:
continue
reverts.append({
"hash": full, "short": short, "date": date, "subject": subject,
"reason": parse_reason(subject), "entities": removed,
})
return reverts
def reverted_index(reverts):
"""name -> (name, qual, kind, revert) using the MOST RECENT revert of that
name (reverts arrive most-recent-first), preferring a qualified form."""
idx = {}
for rev in reverts: # most recent first
for name, qual, kind in rev["entities"]:
cur = idx.get(name)
if cur is None:
idx[name] = (name, qual, kind, rev)
elif cur[1] is None and qual is not None:
# upgrade to a qualified form, keep the more-recent revert we saw
idx[name] = (name, qual, kind, cur[3])
return idx
def classify(prop, rev_entity):
"""prop and rev_entity are (name, qual, kind[, ...]); return a reason-code
or None. Two qualified entities match only if the qualifier matches too."""
pname, pqual = prop[0], prop[1]
rname, rqual = rev_entity[0], rev_entity[1]
if pname != rname:
return None
if pqual is not None and rqual is not None:
return "REINTRODUCES_REVERTED" if pqual == rqual else None
return "NAME_MATCH_UNVERIFIED"
def read_proposed(path):
"""Read the proposed change from a file (or '-' for stdin). Auto-detects a
JSON entity list vs a unified diff. Returns a deduped entity list."""
if path == "-":
data = sys.stdin.read()
else:
try:
with open(path, "r") as fh:
data = fh.read()
except OSError as exc:
_bad("cannot read proposed change %s: %s" % (path, exc))
if not data.strip():
_bad("proposed change %s is empty" % path)
stripped = data.lstrip()
if stripped[:1] in "[{":
try:
obj = json.loads(data)
except ValueError as exc:
_bad("proposed change %s looks like JSON but will not parse: %s"
% (path, exc))
rows = obj if isinstance(obj, list) else obj.get("entities")
if not isinstance(rows, list) or not rows:
_bad("JSON proposed change must be a non-empty list of entities")
ents = []
for i, r in enumerate(rows):
if not isinstance(r, dict):
_bad("entity %d is not an object" % i)
name = r.get("name") or r.get("symbol") or r.get("column")
if not name:
_bad("entity %d has no name/symbol/column" % i)
qual = r.get("table") or r.get("qualifier")
ents.append((str(name).lower(),
str(qual).lower() if qual else None,
str(r.get("kind", "symbol"))))
return dedupe(ents)
ents = entities_from_patch(data, "+")
if not ents:
_bad("no added column/symbol entities found in the proposed diff %s "
"(nothing to check)" % path)
return dedupe(ents)
def build_findings(proposed, idx):
findings = []
for prop in proposed:
best = None
for name, qual, kind, rev in [idx[prop[0]]] if prop[0] in idx else []:
code = classify(prop, (name, qual, kind))
if code is None:
continue
best = (code, prop, (name, qual, kind), rev)
if best:
findings.append(best)
rank = {"REINTRODUCES_REVERTED": 0, "NAME_MATCH_UNVERIFIED": 1}
findings.sort(key=lambda f: (rank[f[0]], f[1][0]))
return findings
def render(repo, proposed_path, proposed, reverts, findings, warn_exit):
n_names = len({n for rev in reverts for (n, _, _) in rev["entities"]})
out = ["REVERT-GUARD REPORT"]
out.append("repo: %s" % repo)
out.append("proposed: %s" % proposed_path)
out.append("revert history: %d revert commit(s), %d reverted entity(ies)"
% (len(reverts), n_names))
out.append("proposed additions: %d entity(ies)" % len(proposed))
blocks = [f for f in findings if f[0] == "REINTRODUCES_REVERTED"]
warns = [f for f in findings if f[0] == "NAME_MATCH_UNVERIFIED"]
out.append("findings:")
if not findings:
out.append(" (none -- no proposed addition matches a reverted entity)")
for code, prop, rev_ent, rev in findings:
label = (rev_ent[0] if rev_ent[1] is None
else "%s.%s" % (rev_ent[1], rev_ent[0]))
prop_label = (prop[0] if prop[1] is None
else "%s.%s" % (prop[1], prop[0]))
out.append(" - %s %s" % (code, prop_label))
if code == "REINTRODUCES_REVERTED":
out.append(" your diff re-adds %s (qualified: table '%s', "
"column '%s')" % (prop_label, prop[1], prop[0]))
else:
out.append(" your diff adds a %s named '%s' (unqualified); "
"the reverted entity is %s" % (prop[2], prop[0], label))
out.append(" reverted %s in %s -- reason: \"%s\""
% (rev["date"], rev["short"], rev["reason"]))
out.append(" revert subject: %s" % rev["subject"])
if blocks:
verdict, code = "BLOCK", 1
reason = ("%d proposed addition(s) reintroduce a change this repo "
"already reverted" % len(blocks))
elif warns:
verdict, code = "WARN", warn_exit
reason = ("%d proposed addition(s) share a name with a reverted entity "
"but could not be confirmed -- a human verifies" % len(warns))
else:
verdict, code = "SHIP", 0
reason = "nothing in this diff was previously reverted in this repo"
out.append("decision: %s -- %s" % (verdict, reason))
body = "\n".join(out) + "\n"
out.append("digest(sha256): %s" % hashlib.sha256(body.encode()).hexdigest())
return "\n".join(out), code
def main(argv):
ap = argparse.ArgumentParser(add_help=True, prog="revert_guard.py")
ap.add_argument("proposed", help="proposed diff / entities.json / - for stdin")
ap.add_argument("--repo", default=".", help="path to the git repo (default: .)")
ap.add_argument("--since", default=None,
help="limit revert scan (git --since, e.g. '6 months ago')")
ap.add_argument("--revert-pattern", default=DEFAULT_REVERT_PATTERN,
help="regex marking a commit as a revert")
ap.add_argument("--warn-exit", type=int, default=1, choices=(0, 1),
help="exit code for a WARN verdict (default 1, fail-closed)")
if len(argv) == 1:
ap.print_usage()
raise SystemExit(2)
args = ap.parse_args(argv[1:])
inside = _git(args.repo, ["rev-parse", "--is-inside-work-tree"]).strip()
if inside != "true":
_bad("%s is not a git work tree" % args.repo)
try:
revert_re = re.compile(args.revert_pattern, re.I)
except re.error as exc:
_bad("bad --revert-pattern: %s" % exc)
proposed = read_proposed(args.proposed)
reverts = find_reverts(args.repo, revert_re, args.since)
idx = reverted_index(reverts)
findings = build_findings(proposed, idx)
report, code = render(args.repo, args.proposed, proposed, reverts,
findings, args.warn_exit)
print(report)
raise SystemExit(code)
if __name__ == "__main__":
main(sys.argv)
The fixtures: two real repos, one proposed diff
The runs below use two actual git repositories, built by a small script (the full builder is at the end of the post, so you can rebuild them byte for byte). Both repos share the same base history, including one unrelated revert of users.legacy_flag. repo_dirty has two extra commits the clean one does not: it added users.card_token, then reverted it. Here is repo_dirty's log:
$ git -C fixtures/repo_dirty log --format='%h %ad %s' --date=short
6be79a1 2026-06-20 chore: index orders.user_id, docs pointer
c2ce7ed 2026-06-05 revert: drop users.card_token (PCI-DSS scope)
b2a3099 2026-06-03 feat: store users.card_token for one-click checkout
21fde1b 2026-05-24 revert: drop users.legacy_flag (unused after v2 launch)
2103408 2026-05-22 feat: add users.legacy_flag for v1 routing
9bd2943 2026-05-20 init: user service skeleton
The proposed change is the same file for both runs: a new migration and a model field, adding users.card_token. Note the migration number is 0042, not the 0007 from the original add. A path-based check would miss this. The tool matches on the entity.
--- /dev/null
+++ b/migrations/0042_add_card_token.sql
@@ -0,0 +1,2 @@
+-- store a tokenized card reference for the new checkout flow
+ALTER TABLE users ADD COLUMN card_token TEXT;
...
+++ b/models/user.py
@@ -9,3 +9,4 @@ class User(Base):
+ card_token = Column(String(255))
The baseline: on a clean repo it ships
Point the diff at repo_clean. That repo has its own revert history (the legacy_flag one), so the scan runs and finds a revert. It just is not this one. My fixture, my run:
$ python3 revert_guard.py proposed_card_token.diff --repo repo_clean
REVERT-GUARD REPORT
repo: repo_clean
proposed: proposed_card_token.diff
revert history: 1 revert commit(s), 1 reverted entity(ies)
proposed additions: 1 entity(ies)
findings:
(none -- no proposed addition matches a reverted entity)
decision: SHIP -- nothing in this diff was previously reverted in this repo
digest(sha256): b96ce3f50d9062eb41b2424cf4544aeffcded6110016d45a3404aeeaf47bd2da
Exit 0. SHIP. The legacy_flag revert was read and correctly ignored, because the diff does not touch it. This is the run that ships today with no gate: the diff is clean, the schema has no card_token, out it goes.
Same diff, dirty repo: block
This is the flip the post exists for. Nothing about the proposed diff changes. The one thing that changes is --repo repo_clean becomes --repo repo_dirty.
$ python3 revert_guard.py proposed_card_token.diff --repo repo_dirty
REVERT-GUARD REPORT
repo: repo_dirty
proposed: proposed_card_token.diff
revert history: 2 revert commit(s), 2 reverted entity(ies)
proposed additions: 1 entity(ies)
findings:
- REINTRODUCES_REVERTED users.card_token
your diff re-adds users.card_token (qualified: table 'users', column 'card_token')
reverted 2026-06-05 in c2ce7ed -- reason: "PCI-DSS scope"
revert subject: revert: drop users.card_token (PCI-DSS scope)
decision: BLOCK -- 1 proposed addition(s) reintroduce a change this repo already reverted
digest(sha256): 11007493d9b2043523a6b97c8f64eb53ff55988b78483060397c386962e3ebab
Exit 1. BLOCK. It found commit c2ce7ed, read what that revert removed, matched users.card_token against the diff, and handed back the date and the stated reason: PCI-DSS scope. Sit with the pair for a second. Same diff, same agent, one exit 0 and one exit 1. If the problem were the agent's memory, deleting one revert commit from one repo would not change the verdict. It does. The variable is not the agent. It is whether the repo remembers.
When nothing was reverted, it still ships
A gate that blocked on everything would be a different kind of useless, so here is the counter-case. On the same repo_dirty, a diff that adds an unrelated, never-reverted column, users.last_login:
$ python3 revert_guard.py proposed_last_login.diff --repo repo_dirty
REVERT-GUARD REPORT
repo: repo_dirty
proposed: proposed_last_login.diff
revert history: 2 revert commit(s), 2 reverted entity(ies)
proposed additions: 1 entity(ies)
findings:
(none -- no proposed addition matches a reverted entity)
decision: SHIP -- nothing in this diff was previously reverted in this repo
digest(sha256): 7facc852a8c4070fb515d4327bcc23a44d3efda8b8822bc0d24f52d57110debb
Exit 0. Both reverts were scanned; neither is last_login; it ships. The gate answers to the revert history, not to a mood.
The third state: a name match it will not pretend to be sure about
Now a harder one. What if the agent adds card_token only as a model field, with no ALTER TABLE line to say which table? The name matches the reverted users.card_token, but the diff never says users. The honest answer is not BLOCK and not SHIP.
$ python3 revert_guard.py proposed_card_token_model_only.diff --repo repo_dirty
REVERT-GUARD REPORT
repo: repo_dirty
proposed: proposed_card_token_model_only.diff
revert history: 2 revert commit(s), 2 reverted entity(ies)
proposed additions: 1 entity(ies)
findings:
- NAME_MATCH_UNVERIFIED card_token
your diff adds a symbol named 'card_token' (unqualified); the reverted entity is users.card_token
reverted 2026-06-05 in c2ce7ed -- reason: "PCI-DSS scope"
revert subject: revert: drop users.card_token (PCI-DSS scope)
decision: WARN -- 1 proposed addition(s) share a name with a reverted entity but could not be confirmed -- a human verifies
digest(sha256): d07bc775aebbb2f2880b050b28f95a73dc235be59449c15a526e8df3cb107c68
Exit 1, but WARN, not BLOCK. It surfaces the match and refuses to escalate to a hard block on a name it could not qualify. WARN is fail-closed by default because a hidden re-add is worse than a false alarm, but the exit is a flag you own. If your pipeline wants WARN to pass, --warn-exit 0 gives it exit 0 while the report text is identical (same digest, d07bc775...). I went back and forth on that default and I would not fight hard for it. Fail-closed felt right for a gate; your risk tolerance may differ.
If you would rather feed the guard a structured list than a diff, it also reads a JSON array of entities, [{"name": "card_token", "table": "users"}], and treats a table the same way it treats a qualifier parsed from SQL. That path blocks on repo_dirty exactly like the diff does.
About Selvedge, and about not overclaiming
@masondelan's fix and mine solve the same pain from opposite ends, and I want to be precise about the difference rather than imply I beat anything. Selvedge, as they describe it, is a runtime MCP server the agent queries mid-session: it asks prior-attempts and gets an answer back, so the model can course-correct while it plans. That is a good design and it lives inside the agent loop. revert_guard.py is not that. It is an offline gate outside the loop, that runs on the proposed diff before the commit, needs no server and no key, and reads history the team already has. Different mechanism, same failure mode. My angle is not "better than Selvedge." It is that this specific class of mistake can also be caught by a deterministic gate with nothing running, which is a cheaper thing to add on a Friday.
There is a broader argument going around that anything a deterministic system can do reliably should not be handed to a probabilistic one on every call. A revert check is a clean example. Whether a column was reverted is a fact in the log, not a judgment call. You do not need a model to answer it, you need a grep with taste, and the answer should be the same every time you ask. That is why the tool hashes its own output.
Where this sits next to the rest
This is a spoke on the pre-execution gate for AI agents cluster, and its object is the moment before a schema change commits. The neighbors ask adjacent questions:
- The agent memory tax and backdoor post is about what it costs to give an agent memory and how that context can be poisoned. This is the deliberate contrast.
revert_guard.pystores nothing and embeds nothing; it reads the git history you already keep, at zero storage cost. The fix for "the reasoning died" here is not more memory, it is a check against a record you never threw away. - Agent-authored SQL reaches the database shares the object of this demo, a migration and a column arriving at the DB, but asks whether the SQL string is safe to run. This asks whether the change should be proposed at all, given it was already pulled once.
- Your agent returns 200 and lies is the franchise in one line: the system is tracking something (a 200, a revert) while nothing controls the gap between what is recorded and what is enforced. Same shape, runtime instead of pre-commit.
- The authz gate: trace vs allowed is the other contrast to state plainly. That gate answers "is this actor allowed to do this." This one assumes the change is fully allowed and still stops it, because allowed and already-rejected are different questions.
What this is NOT
I would rather undersell this than have you wire it in as something it isn't.
- It is not agent memory or a RAG store. It stores nothing and embeds no reasoning. It reads reverts out of the git history the team already maintains. Storage cost is zero, and there is no context to poison.
- It is not an authorization layer. It does not decide who may change what. The change in the demo is fully permitted by any sane policy. The problem is that it was tried and rolled back, which is a different axis entirely.
- It does not understand why. It matches entity names, not intent. A column reverted for reason X that is now legitimately needed for reason Y will still be flagged, and it should be: the tool hands a human the prior revert and its reason and lets them override. It surfaces; it does not judge.
- It is not a linter or a type checker. It says nothing about whether the code is correct. Its one job is the fact of re-adding something previously reverted.
-
The blind spot is git hygiene, and it is real. It only sees reverts that are actually committed with a message the pattern matches. A rollback done by force-push, a squash-merge that swallowed the revert, an
amendthat rewrote it out of history: invisible. Garbage history in, garbage gate out. It is exactly as good as your team's revert discipline. -
It can also over-fire, not only miss. That blind spot is the false negative. The mirror is the false alarm: it calls a commit a revert when the message trips the revert pattern and the same commit drops an
ADD COLUMNline, so a migration squash or a rename that merely says "revert" in passing can get logged as a revert ofusers.card_tokenand block a later honest add. It also keys off the revert record, not the current schema, so a column that was reverted and then legitimately restored still trips it. Same trade either way: fail loud, hand a human the commit, let them clear it. -
It does not run the agent, or anything else. Offline, read-only, zero network. It reads a diff as text and shells out to a local
git log/git show. It never executes the diff, calls a model, or opens a socket. - It is not a replacement for code review. It is a pre-review stretch over one failure mode, the quiet re-add of a reverted change, not a general bug hunter. Keep the reviewer.
-
The numbers here are fixture units. The exit codes, the
2026-06-05, thec2ce7ed, the hashes: all synthetic, from a repo I built for this post. Thecard_token/ PCI-DSS story and Selvedge belong to @masondelan. Run the tool on your own repo to get anything that means something about your agent.
Bad input fails closed
A gate that crashes into a green is worse than no gate. Point it at something that is not a git repo and it refuses to decide:
$ python3 revert_guard.py proposed_card_token.diff --repo not_a_repo
ERROR: git rev-parse --is-inside-work-tree failed in not_a_repo: fatal: not a git repository (or any of the parent directories): .git
$ python3 revert_guard.py ; echo "exit=$?"
usage: revert_guard.py [-h] [--repo REPO] [--since SINCE]
[--revert-pattern REVERT_PATTERN] [--warn-exit {0,1}]
proposed
exit=2
Both exit 2, distinct from the exit 1 a BLOCK or WARN returns, so CI can tell "the gate says hold" apart from "the gate could not run." One honest caveat: git resolves upward, so if you point --repo at a plain subdirectory inside another checkout, git will find that outer repo instead of erroring. Give it a path that is genuinely outside a work tree to see the exit 2 above.
On determinism: I ran each report scenario twice, offline, on Python 3.13.5, and hashed the full stdout both times. Identical every time. The tool also prints a digest(sha256) of its own report, so you can verify a run without trusting me: drop the last line and hash the rest. The SHIP baseline is b96ce3f5..., the killer BLOCK is 11007493..., the WARN is d07bc775..., the unrelated SHIP is 7facc852.... Absolute dates, not "N days ago," precisely so tomorrow's run has the same hash as today's.
Reproduce the fixtures
The two repos and the proposed diffs are built by this script. Author identity and every commit date are pinned, so a rebuild lands the same commit SHAs (and short hashes like c2ce7ed) the post prints.
#!/usr/bin/env python3
"""
make_fixtures.py -- builds the two real git repositories and the proposed-diff
files that revert_guard.py runs on in the post. It only writes DATA and calls a
LOCAL git to create commits; nothing here is executed by the guard.
Determinism: author/committer identity and every commit date are pinned, so a
rebuild produces the same commit SHAs (and therefore the same short hashes) the
post prints. Signing/hooks are disabled so a contributor's global git config
cannot change the objects.
fixtures/repo_clean -- init, add users.legacy_flag, REVERT legacy_flag, noise.
One revert commit. Never touched card_token.
fixtures/repo_dirty -- the same, PLUS: add users.card_token, REVERT card_token
(message: "PCI-DSS scope"), noise. Two revert commits.
fixtures/proposed_card_token.diff -- new migration 0042 + model field
adding users.card_token (qualified).
fixtures/proposed_card_token_model_only.diff -- only the model field (bare).
fixtures/proposed_last_login.diff -- an unrelated, never-reverted column.
fixtures/proposed_card_token.entities.json -- the JSON-entity input form.
fixtures/not_a_repo/ -- a plain dir for the bad-input case.
"""
import os
import shutil
import subprocess
BASE = os.path.dirname(os.path.abspath(__file__))
FIX = os.path.join(BASE, "fixtures")
ENV = dict(os.environ)
ENV.update({
"GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid",
})
USER_PY_BASE = '''\
from db import Base, Column, String, DateTime, Integer
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False)
'''
INIT_SQL = "CREATE TABLE users (\n id INTEGER PRIMARY KEY,\n email TEXT NOT NULL,\n created_at TIMESTAMP NOT NULL\n);\n"
def run(repo, args, date=None):
env = dict(ENV)
if date:
env["GIT_AUTHOR_DATE"] = date
env["GIT_COMMITTER_DATE"] = date
subprocess.run(["git", "-c", "commit.gpgsign=false", "-c", "gc.auto=0",
"-C", repo] + args, env=env, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def write(repo, rel, text):
path = os.path.join(repo, rel)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write(text)
def commit(repo, date, message):
run(repo, ["add", "-A"])
run(repo, ["commit", "--no-verify", "-q", "-m", message], date=date)
def init_repo(repo):
if os.path.exists(repo):
shutil.rmtree(repo)
os.makedirs(repo)
run(repo, ["init", "-q", "-b", "main"])
def add_field(repo, field, coltype):
text = USER_PY_BASE.rstrip("\n") + "\n %s = Column(%s)\n" % (field, coltype)
write(repo, "models/user.py", text)
def base_history(repo):
# 1) init
write(repo, "README.md", "# service\n\nInternal user service.\n")
write(repo, "models/user.py", USER_PY_BASE)
write(repo, "migrations/0001_init.sql", INIT_SQL)
commit(repo, "2026-05-20T10:00:00", "init: user service skeleton")
# 2) add users.legacy_flag
write(repo, "migrations/0003_add_legacy_flag.sql",
"ALTER TABLE users ADD COLUMN legacy_flag BOOLEAN DEFAULT false;\n")
add_field(repo, "legacy_flag", "String(8)")
commit(repo, "2026-05-22T09:30:00", "feat: add users.legacy_flag for v1 routing")
# 3) REVERT legacy_flag (unrelated revert, shared by both repos)
os.remove(os.path.join(repo, "migrations/0003_add_legacy_flag.sql"))
write(repo, "models/user.py", USER_PY_BASE)
commit(repo, "2026-05-24T14:15:00",
"revert: drop users.legacy_flag (unused after v2 launch)")
def noise(repo, date):
write(repo, "migrations/0009_add_orders_index.sql",
"CREATE INDEX idx_orders_user ON orders (user_id);\n")
write(repo, "README.md", "# service\n\nInternal user service. See /docs.\n")
commit(repo, date, "chore: index orders.user_id, docs pointer")
def build_clean():
repo = os.path.join(FIX, "repo_clean")
init_repo(repo)
base_history(repo)
noise(repo, "2026-06-20T11:00:00")
def build_dirty():
repo = os.path.join(FIX, "repo_dirty")
init_repo(repo)
base_history(repo)
# 4) add users.card_token
write(repo, "migrations/0007_add_card_token.sql",
"ALTER TABLE users ADD COLUMN card_token TEXT;\n")
add_field(repo, "card_token", "String(255)")
commit(repo, "2026-06-03T13:20:00",
"feat: store users.card_token for one-click checkout")
# 5) REVERT card_token -- the entity the killer demo re-proposes
os.remove(os.path.join(repo, "migrations/0007_add_card_token.sql"))
write(repo, "models/user.py", USER_PY_BASE)
commit(repo, "2026-06-05T16:45:00",
"revert: drop users.card_token (PCI-DSS scope)")
# 6) noise so the revert is not the latest commit
noise(repo, "2026-06-20T11:00:00")
PROPOSED_CARD_TOKEN = '''\
diff --git a/migrations/0042_add_card_token.sql b/migrations/0042_add_card_token.sql
new file mode 100644
index 0000000..a1a1a1a
--- /dev/null
+++ b/migrations/0042_add_card_token.sql
@@ -0,0 +1,2 @@
+-- store a tokenized card reference for the new checkout flow
+ALTER TABLE users ADD COLUMN card_token TEXT;
diff --git a/models/user.py b/models/user.py
index b2b2b2b..c3c3c3c 100644
--- a/models/user.py
+++ b/models/user.py
@@ -9,3 +9,4 @@ class User(Base):
email = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False)
+ card_token = Column(String(255))
'''
PROPOSED_MODEL_ONLY = '''\
diff --git a/models/user.py b/models/user.py
index b2b2b2b..d4d4d4d 100644
--- a/models/user.py
+++ b/models/user.py
@@ -9,3 +9,4 @@ class User(Base):
email = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False)
+ card_token = Column(String(255))
'''
PROPOSED_LAST_LOGIN = '''\
diff --git a/migrations/0043_add_last_login.sql b/migrations/0043_add_last_login.sql
new file mode 100644
index 0000000..e5e5e5e
--- /dev/null
+++ b/migrations/0043_add_last_login.sql
@@ -0,0 +1,1 @@
+ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
'''
PROPOSED_JSON = '''\
[
{"name": "card_token", "table": "users", "kind": "column"}
]
'''
def build_proposed():
with open(os.path.join(FIX, "proposed_card_token.diff"), "w") as fh:
fh.write(PROPOSED_CARD_TOKEN)
with open(os.path.join(FIX, "proposed_card_token_model_only.diff"), "w") as fh:
fh.write(PROPOSED_MODEL_ONLY)
with open(os.path.join(FIX, "proposed_last_login.diff"), "w") as fh:
fh.write(PROPOSED_LAST_LOGIN)
with open(os.path.join(FIX, "proposed_card_token.entities.json"), "w") as fh:
fh.write(PROPOSED_JSON)
nar = os.path.join(FIX, "not_a_repo")
os.makedirs(nar, exist_ok=True)
with open(os.path.join(nar, "hello.txt"), "w") as fh:
fh.write("this directory is deliberately not a git repo\n")
def main():
os.makedirs(FIX, exist_ok=True)
build_clean()
build_dirty()
build_proposed()
print("fixtures built in %s" % FIX)
if __name__ == "__main__":
main()
The question I actually want answered
Here is the one I do not have a good number for. When a fresh agent session re-proposes something your team reverted, what catches it today? My honest guess is: usually nothing, until a reviewer happens to remember, and memory is a bad place to keep a safety property. But that is a guess. If your team has a real mechanism, an MCP lookup like Selvedge, a lint rule, a convention, I want to hear which one and whether it has actually stopped a re-add in practice.
If this was useful, follow along for the next runnable gate in this series, and tell me in the comments: has your agent ever re-proposed a change you had already reverted, and did anything catch it before it hit review? I read every one.
Top comments (0)