{"slug": "your-ai-agent-re-adds-code-you-reverted-last-month", "title": "Your AI agent re-adds code you reverted last month", "summary": "A developer created revert_guard.py, an offline pre-commit gate that blocks AI agents from re-adding code that was previously reverted in a git repository. The tool reads the repo's revert history and exits with an error if a diff reintroduces a previously reverted entity, preventing issues like the one where a Claude Code session re-proposed a PCI-DSS-scoped column that had been reverted a month earlier.", "body_md": "AI agent re-adds reverted code when a fresh session, with no memory of last month's decision, re-proposes it. `revert_guard.py`\n\nis 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.\n\nAI disclosure:I wrote`revert_guard.py`\n\nwith 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. The`card_token`\n\n/ 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 the`2026-06-05`\n\nrevert 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.\n\n**In short:**\n\n`card_token`\n\ncolumn, 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 in `git log`\n\n.`users.card_token`\n\n), not by file path, so a fresh migration number does not slip past.`users.card_token`\n\n. Point it at `repo_clean`\n\nand it exits 0 (SHIP). Point it at `repo_dirty`\n\n, where that column was added and reverted, and it exits 1 (BLOCK) and prints `reverted 2026-06-05 in c2ce7ed -- reason: \"PCI-DSS scope\"`\n\n. The only variable is the repo's revert history.`git log`\n\n/ `git show`\n\non 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.On July 6, an engineer posting as [@masondelan](https://dev.to/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`\n\ncolumn to their `users`\n\ntable, 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`\n\nwith 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.\n\nHere 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.\n\nSo 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.\n\nThree verdicts, one rule, read off the repo's own reverts.\n\n`users.card_token`\n\n, 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.`card_token`\n\nin a model file is definitely the reverted `users.card_token`\n\n.The distinction the whole thing turns on: `ALTER TABLE users ADD COLUMN card_token`\n\ngives a *qualified* entity, because the table is right there on the line. A lone `card_token = Column(...)`\n\nin 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.\n\nNo 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:\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"\nrevert_guard.py -- an offline pre-commit gate that reads a repo's OWN git revert\nhistory and blocks (exit 1) an AI agent's proposed diff that reintroduces a\ncolumn / symbol / flag the team already added and then REVERTED -- before the\ndiff is committed.\n\nIt takes a proposed change (a unified diff, or a JSON list of entities) plus a\n`--repo` path. It shells out to a LOCAL `git log` / `git show` on READ only,\nfinds the commits whose message marks them as a revert, extracts the entities\nthose reverts removed, and intersects that set with the entities the proposed\ndiff adds. The match is by ENTITY (a column name, table-qualified when it can be:\n`users.card_token`), never by file path -- so a brand-new migration file with a\ndifferent number is still caught.\n\n  REINTRODUCES_REVERTED -- a table-qualified entity in the diff (e.g.\n                           users.card_token) exactly equals one a revert commit\n                           removed. BLOCK. Prints the revert's short hash, date,\n                           and stated reason.\n  NAME_MATCH_UNVERIFIED -- the bare name matches a reverted entity but one side\n                           is unqualified, so it cannot be confirmed the SAME\n                           table's column. WARN, fail-closed (a human verifies).\n  NO_REVERT_MATCH       -- nothing the diff adds was ever reverted here. SHIP.\n  BAD_INPUT             -- not a git repo, unreadable/empty diff, bad JSON.\n\nThe point the tool exists to make: take ONE proposed diff -- a new migration that\nadds users.card_token -- and run it against two repos. On a repo that never\nreverted that column it exits 0 (SHIP). On a repo where the same column was added\nand reverted last month it exits 1 (BLOCK), and prints the prior revert. Same\ndiff, same agent; the only variable is whether the REPO remembers the revert.\nThis is not about the cost of agent memory and not about permissions -- the agent\nre-proposes what the team already reverted, because the reasoning died with the\nsession, while the revert did not.\n\nOffline. Keyless. Read-only. Zero network. Standard library only (subprocess for\n`git` on read, sys, re, json, hashlib, argparse). It never writes, never commits,\nnever runs the agent, never touches the network, and reads the diff as text --\nit is DATA, never executed. Output is byte-for-byte deterministic across runs on\nthe same repo; it prints absolute revert dates (not \"N days ago\") on purpose, so\nthe output does not change with the calendar, and ends with a sha256 digest of\nits own report so two runs are provably identical.\n\nIt does NOT store memory or embed reasoning; it reads git history the team already\nkeeps. It does NOT decide who is allowed to change what. It does NOT understand\nWHY: it matches names, not intent, so a column reverted for reason X and now\nlegitimately needed for reason Y is still flagged for a human to override. It only\nsees reverts that are actually COMMITTED -- a revert done by force-push, squash,\nor amend-out-of-history is a blind spot. It is as good as the team's git history\nis honest.\n\nExit codes (usable as a pre-commit / CI gate):\n  0  SHIP  (no proposed addition was previously reverted here)\n  1  BLOCK or WARN  (both mean \"do not auto-apply\"; the reason-code says which).\n     WARN's exit is configurable via --warn-exit (default 1, fail-closed).\n  2  bad input: not a git repo, missing/unreadable/empty diff, unparseable JSON\n     -- fail-closed.\n\nUsage:\n  python3 revert_guard.py <proposed.diff | entities.json | -> --repo <path>\n  python3 revert_guard.py proposed.diff --repo ./service\n  git diff --cached | python3 revert_guard.py - --repo .\n\"\"\"\n\nimport argparse\nimport hashlib\nimport json\nimport re\nimport subprocess\nimport sys\n\n# A commit is treated as a revert if its subject or body matches this. Covers a\n# native `git revert` (\"This reverts commit <hash>\") and hand-written reverts.\nDEFAULT_REVERT_PATTERN = (\n    r\"(this reverts commit|\\brevert(s|ed)?\\b|\\brolled back\\b|\\bbacked out\\b)\"\n)\n\n# SQL: `ALTER TABLE users ADD COLUMN card_token ...` -> ('users', 'card_token').\nRE_ALTER_ADD = re.compile(\n    r\"alter\\s+table\\s+[\\\"`']?(\\w+)[\\\"`']?.*?\\badd\\s+column\\s+\"\n    r\"(?:if\\s+not\\s+exists\\s+)?[\\\"`']?(\\w+)\",\n    re.I,\n)\n# SQL: bare `ADD COLUMN card_token` with no table on the line -> ('card_token',).\nRE_ADD_COLUMN = re.compile(\n    r\"\\badd\\s+column\\s+(?:if\\s+not\\s+exists\\s+)?[\\\"`']?(\\w+)\", re.I\n)\n# Python model field: `card_token = Column(...)` / `x: Mapped[str] = mapped_column(`\nRE_PY_COLUMN = re.compile(\n    r\"^\\s*(\\w+)\\s*(?::[^=]+)?=\\s*(?:\\w+\\.)*(?:mapped_column|Column)\\s*\\(\"\n)\n\ndef _bad(msg):\n    print(\"ERROR: \" + msg)\n    raise SystemExit(2)\n\ndef _git(repo, args):\n    \"\"\"Run a read-only git command, return stdout. Never writes.\"\"\"\n    try:\n        proc = subprocess.run(\n            [\"git\", \"-c\", \"core.quotepath=false\", \"-C\", repo] + args,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.PIPE,\n            check=False,\n            text=True,\n        )\n    except OSError as exc:  # git not installed\n        _bad(\"cannot run git: %s\" % exc)\n    if proc.returncode != 0:\n        _bad(\"git %s failed in %s: %s\"\n             % (\" \".join(args), repo, proc.stderr.strip()))\n    return proc.stdout\n\ndef entities_from_line(text):\n    \"\"\"Extract 0..1 entity from a single added/removed diff line.\n\n    Returns a list of (name, qualifier_or_None, kind). Names/qualifiers are\n    lower-cased so `Users.Card_Token` and `users.card_token` normalize together.\n    \"\"\"\n    m = RE_ALTER_ADD.search(text)\n    if m:\n        return [(m.group(2).lower(), m.group(1).lower(), \"column\")]\n    m = RE_ADD_COLUMN.search(text)\n    if m:\n        return [(m.group(1).lower(), None, \"column\")]\n    m = RE_PY_COLUMN.match(text)\n    if m:\n        return [(m.group(1).lower(), None, \"symbol\")]\n    return []\n\ndef entities_from_patch(patch, sign):\n    \"\"\"Collect entities from the +/- content lines of a unified diff.\n\n    sign='+' reads added lines (a proposed change); sign='-' reads removed lines\n    (what a revert took out). File headers (+++/---) are skipped.\n    \"\"\"\n    out = []\n    for line in patch.splitlines():\n        if not line or line[0] != sign:\n            continue\n        if line.startswith(\"+++\") or line.startswith(\"---\"):\n            continue\n        out.extend(entities_from_line(line[1:]))\n    return out\n\ndef dedupe(entities):\n    \"\"\"One entity per name; keep the qualified form when both exist.\"\"\"\n    by_name = {}\n    for name, qual, kind in entities:\n        cur = by_name.get(name)\n        if cur is None or (cur[1] is None and qual is not None):\n            by_name[name] = (name, qual, kind)\n    return [by_name[k] for k in sorted(by_name)]\n\ndef parse_reason(subject):\n    \"\"\"Prefer the trailing parenthetical as the human reason, else the subject.\"\"\"\n    m = re.search(r\"\\(([^()]+)\\)\\s*$\", subject)\n    return m.group(1).strip() if m else subject.strip()\n\ndef find_reverts(repo, revert_re, since):\n    \"\"\"Return a list of revert commits, most recent first, each with the\n    entities it removed. dict: hash, short, date, subject, reason, entities.\"\"\"\n    fmt = \"%H%x1f%h%x1f%ad%x1f%s%x1f%b%x1e\"\n    args = [\"log\", \"--no-color\", \"--date=short\", \"--format=\" + fmt]\n    if since:\n        args.append(\"--since=\" + since)\n    raw = _git(repo, args)\n    reverts = []\n    for record in raw.split(\"\\x1e\"):\n        record = record.strip(\"\\n\")\n        if not record:\n            continue\n        parts = record.split(\"\\x1f\")\n        if len(parts) < 5:\n            continue\n        full, short, date, subject, body = parts[0], parts[1], parts[2], parts[3], parts[4]\n        if not revert_re.search(subject + \"\\n\" + body):\n            continue\n        patch = _git(repo, [\"show\", \"--no-color\", \"--format=\", \"-U0\", full])\n        removed = dedupe(entities_from_patch(patch, \"-\"))\n        if not removed:\n            continue\n        reverts.append({\n            \"hash\": full, \"short\": short, \"date\": date, \"subject\": subject,\n            \"reason\": parse_reason(subject), \"entities\": removed,\n        })\n    return reverts\n\ndef reverted_index(reverts):\n    \"\"\"name -> (name, qual, kind, revert) using the MOST RECENT revert of that\n    name (reverts arrive most-recent-first), preferring a qualified form.\"\"\"\n    idx = {}\n    for rev in reverts:  # most recent first\n        for name, qual, kind in rev[\"entities\"]:\n            cur = idx.get(name)\n            if cur is None:\n                idx[name] = (name, qual, kind, rev)\n            elif cur[1] is None and qual is not None:\n                # upgrade to a qualified form, keep the more-recent revert we saw\n                idx[name] = (name, qual, kind, cur[3])\n    return idx\n\ndef classify(prop, rev_entity):\n    \"\"\"prop and rev_entity are (name, qual, kind[, ...]); return a reason-code\n    or None. Two qualified entities match only if the qualifier matches too.\"\"\"\n    pname, pqual = prop[0], prop[1]\n    rname, rqual = rev_entity[0], rev_entity[1]\n    if pname != rname:\n        return None\n    if pqual is not None and rqual is not None:\n        return \"REINTRODUCES_REVERTED\" if pqual == rqual else None\n    return \"NAME_MATCH_UNVERIFIED\"\n\ndef read_proposed(path):\n    \"\"\"Read the proposed change from a file (or '-' for stdin). Auto-detects a\n    JSON entity list vs a unified diff. Returns a deduped entity list.\"\"\"\n    if path == \"-\":\n        data = sys.stdin.read()\n    else:\n        try:\n            with open(path, \"r\") as fh:\n                data = fh.read()\n        except OSError as exc:\n            _bad(\"cannot read proposed change %s: %s\" % (path, exc))\n    if not data.strip():\n        _bad(\"proposed change %s is empty\" % path)\n    stripped = data.lstrip()\n    if stripped[:1] in \"[{\":\n        try:\n            obj = json.loads(data)\n        except ValueError as exc:\n            _bad(\"proposed change %s looks like JSON but will not parse: %s\"\n                 % (path, exc))\n        rows = obj if isinstance(obj, list) else obj.get(\"entities\")\n        if not isinstance(rows, list) or not rows:\n            _bad(\"JSON proposed change must be a non-empty list of entities\")\n        ents = []\n        for i, r in enumerate(rows):\n            if not isinstance(r, dict):\n                _bad(\"entity %d is not an object\" % i)\n            name = r.get(\"name\") or r.get(\"symbol\") or r.get(\"column\")\n            if not name:\n                _bad(\"entity %d has no name/symbol/column\" % i)\n            qual = r.get(\"table\") or r.get(\"qualifier\")\n            ents.append((str(name).lower(),\n                         str(qual).lower() if qual else None,\n                         str(r.get(\"kind\", \"symbol\"))))\n        return dedupe(ents)\n    ents = entities_from_patch(data, \"+\")\n    if not ents:\n        _bad(\"no added column/symbol entities found in the proposed diff %s \"\n             \"(nothing to check)\" % path)\n    return dedupe(ents)\n\ndef build_findings(proposed, idx):\n    findings = []\n    for prop in proposed:\n        best = None\n        for name, qual, kind, rev in [idx[prop[0]]] if prop[0] in idx else []:\n            code = classify(prop, (name, qual, kind))\n            if code is None:\n                continue\n            best = (code, prop, (name, qual, kind), rev)\n        if best:\n            findings.append(best)\n    rank = {\"REINTRODUCES_REVERTED\": 0, \"NAME_MATCH_UNVERIFIED\": 1}\n    findings.sort(key=lambda f: (rank[f[0]], f[1][0]))\n    return findings\n\ndef render(repo, proposed_path, proposed, reverts, findings, warn_exit):\n    n_names = len({n for rev in reverts for (n, _, _) in rev[\"entities\"]})\n    out = [\"REVERT-GUARD REPORT\"]\n    out.append(\"repo: %s\" % repo)\n    out.append(\"proposed: %s\" % proposed_path)\n    out.append(\"revert history: %d revert commit(s), %d reverted entity(ies)\"\n               % (len(reverts), n_names))\n    out.append(\"proposed additions: %d entity(ies)\" % len(proposed))\n    blocks = [f for f in findings if f[0] == \"REINTRODUCES_REVERTED\"]\n    warns = [f for f in findings if f[0] == \"NAME_MATCH_UNVERIFIED\"]\n    out.append(\"findings:\")\n    if not findings:\n        out.append(\"  (none -- no proposed addition matches a reverted entity)\")\n    for code, prop, rev_ent, rev in findings:\n        label = (rev_ent[0] if rev_ent[1] is None\n                 else \"%s.%s\" % (rev_ent[1], rev_ent[0]))\n        prop_label = (prop[0] if prop[1] is None\n                      else \"%s.%s\" % (prop[1], prop[0]))\n        out.append(\"  - %s  %s\" % (code, prop_label))\n        if code == \"REINTRODUCES_REVERTED\":\n            out.append(\"      your diff re-adds %s (qualified: table '%s', \"\n                       \"column '%s')\" % (prop_label, prop[1], prop[0]))\n        else:\n            out.append(\"      your diff adds a %s named '%s' (unqualified); \"\n                       \"the reverted entity is %s\" % (prop[2], prop[0], label))\n        out.append(\"      reverted %s in %s -- reason: \\\"%s\\\"\"\n                   % (rev[\"date\"], rev[\"short\"], rev[\"reason\"]))\n        out.append(\"      revert subject: %s\" % rev[\"subject\"])\n    if blocks:\n        verdict, code = \"BLOCK\", 1\n        reason = (\"%d proposed addition(s) reintroduce a change this repo \"\n                  \"already reverted\" % len(blocks))\n    elif warns:\n        verdict, code = \"WARN\", warn_exit\n        reason = (\"%d proposed addition(s) share a name with a reverted entity \"\n                  \"but could not be confirmed -- a human verifies\" % len(warns))\n    else:\n        verdict, code = \"SHIP\", 0\n        reason = \"nothing in this diff was previously reverted in this repo\"\n    out.append(\"decision: %s -- %s\" % (verdict, reason))\n    body = \"\\n\".join(out) + \"\\n\"\n    out.append(\"digest(sha256): %s\" % hashlib.sha256(body.encode()).hexdigest())\n    return \"\\n\".join(out), code\n\ndef main(argv):\n    ap = argparse.ArgumentParser(add_help=True, prog=\"revert_guard.py\")\n    ap.add_argument(\"proposed\", help=\"proposed diff / entities.json / - for stdin\")\n    ap.add_argument(\"--repo\", default=\".\", help=\"path to the git repo (default: .)\")\n    ap.add_argument(\"--since\", default=None,\n                    help=\"limit revert scan (git --since, e.g. '6 months ago')\")\n    ap.add_argument(\"--revert-pattern\", default=DEFAULT_REVERT_PATTERN,\n                    help=\"regex marking a commit as a revert\")\n    ap.add_argument(\"--warn-exit\", type=int, default=1, choices=(0, 1),\n                    help=\"exit code for a WARN verdict (default 1, fail-closed)\")\n    if len(argv) == 1:\n        ap.print_usage()\n        raise SystemExit(2)\n    args = ap.parse_args(argv[1:])\n\n    inside = _git(args.repo, [\"rev-parse\", \"--is-inside-work-tree\"]).strip()\n    if inside != \"true\":\n        _bad(\"%s is not a git work tree\" % args.repo)\n    try:\n        revert_re = re.compile(args.revert_pattern, re.I)\n    except re.error as exc:\n        _bad(\"bad --revert-pattern: %s\" % exc)\n\n    proposed = read_proposed(args.proposed)\n    reverts = find_reverts(args.repo, revert_re, args.since)\n    idx = reverted_index(reverts)\n    findings = build_findings(proposed, idx)\n    report, code = render(args.repo, args.proposed, proposed, reverts,\n                          findings, args.warn_exit)\n    print(report)\n    raise SystemExit(code)\n\nif __name__ == \"__main__\":\n    main(sys.argv)\n```\n\nThe 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`\n\n. `repo_dirty`\n\nhas two extra commits the clean one does not: it added `users.card_token`\n\n, then reverted it. Here is `repo_dirty`\n\n's log:\n\n``` bash\n$ git -C fixtures/repo_dirty log --format='%h %ad %s' --date=short\n6be79a1 2026-06-20 chore: index orders.user_id, docs pointer\nc2ce7ed 2026-06-05 revert: drop users.card_token (PCI-DSS scope)\nb2a3099 2026-06-03 feat: store users.card_token for one-click checkout\n21fde1b 2026-05-24 revert: drop users.legacy_flag (unused after v2 launch)\n2103408 2026-05-22 feat: add users.legacy_flag for v1 routing\n9bd2943 2026-05-20 init: user service skeleton\n```\n\nThe proposed change is the same file for both runs: a new migration and a model field, adding `users.card_token`\n\n. Note the migration number is `0042`\n\n, not the `0007`\n\nfrom the original add. A path-based check would miss this. The tool matches on the entity.\n\n```\n--- /dev/null\n+++ b/migrations/0042_add_card_token.sql\n@@ -0,0 +1,2 @@\n+-- store a tokenized card reference for the new checkout flow\n+ALTER TABLE users ADD COLUMN card_token TEXT;\n...\n+++ b/models/user.py\n@@ -9,3 +9,4 @@ class User(Base):\n+    card_token = Column(String(255))\n```\n\nPoint the diff at `repo_clean`\n\n. That repo has its own revert history (the `legacy_flag`\n\none), so the scan runs and finds a revert. It just is not this one. My fixture, my run:\n\n``` bash\n$ python3 revert_guard.py proposed_card_token.diff --repo repo_clean\nREVERT-GUARD REPORT\nrepo: repo_clean\nproposed: proposed_card_token.diff\nrevert history: 1 revert commit(s), 1 reverted entity(ies)\nproposed additions: 1 entity(ies)\nfindings:\n  (none -- no proposed addition matches a reverted entity)\ndecision: SHIP -- nothing in this diff was previously reverted in this repo\ndigest(sha256): b96ce3f50d9062eb41b2424cf4544aeffcded6110016d45a3404aeeaf47bd2da\n```\n\nExit 0. SHIP. The `legacy_flag`\n\nrevert 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`\n\n, out it goes.\n\nThis is the flip the post exists for. Nothing about the proposed diff changes. The one thing that changes is `--repo repo_clean`\n\nbecomes `--repo repo_dirty`\n\n.\n\n``` bash\n$ python3 revert_guard.py proposed_card_token.diff --repo repo_dirty\nREVERT-GUARD REPORT\nrepo: repo_dirty\nproposed: proposed_card_token.diff\nrevert history: 2 revert commit(s), 2 reverted entity(ies)\nproposed additions: 1 entity(ies)\nfindings:\n  - REINTRODUCES_REVERTED  users.card_token\n      your diff re-adds users.card_token (qualified: table 'users', column 'card_token')\n      reverted 2026-06-05 in c2ce7ed -- reason: \"PCI-DSS scope\"\n      revert subject: revert: drop users.card_token (PCI-DSS scope)\ndecision: BLOCK -- 1 proposed addition(s) reintroduce a change this repo already reverted\ndigest(sha256): 11007493d9b2043523a6b97c8f64eb53ff55988b78483060397c386962e3ebab\n```\n\nExit 1. BLOCK. It found commit `c2ce7ed`\n\n, read what that revert removed, matched `users.card_token`\n\nagainst the diff, and handed back the date and the stated reason: `PCI-DSS scope`\n\n. 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.\n\nA gate that blocked on everything would be a different kind of useless, so here is the counter-case. On the same `repo_dirty`\n\n, a diff that adds an unrelated, never-reverted column, `users.last_login`\n\n:\n\n``` bash\n$ python3 revert_guard.py proposed_last_login.diff --repo repo_dirty\nREVERT-GUARD REPORT\nrepo: repo_dirty\nproposed: proposed_last_login.diff\nrevert history: 2 revert commit(s), 2 reverted entity(ies)\nproposed additions: 1 entity(ies)\nfindings:\n  (none -- no proposed addition matches a reverted entity)\ndecision: SHIP -- nothing in this diff was previously reverted in this repo\ndigest(sha256): 7facc852a8c4070fb515d4327bcc23a44d3efda8b8822bc0d24f52d57110debb\n```\n\nExit 0. Both reverts were scanned; neither is `last_login`\n\n; it ships. The gate answers to the revert history, not to a mood.\n\nNow a harder one. What if the agent adds `card_token`\n\nonly as a model field, with no `ALTER TABLE`\n\nline to say which table? The name matches the reverted `users.card_token`\n\n, but the diff never says `users`\n\n. The honest answer is not BLOCK and not SHIP.\n\n``` bash\n$ python3 revert_guard.py proposed_card_token_model_only.diff --repo repo_dirty\nREVERT-GUARD REPORT\nrepo: repo_dirty\nproposed: proposed_card_token_model_only.diff\nrevert history: 2 revert commit(s), 2 reverted entity(ies)\nproposed additions: 1 entity(ies)\nfindings:\n  - NAME_MATCH_UNVERIFIED  card_token\n      your diff adds a symbol named 'card_token' (unqualified); the reverted entity is users.card_token\n      reverted 2026-06-05 in c2ce7ed -- reason: \"PCI-DSS scope\"\n      revert subject: revert: drop users.card_token (PCI-DSS scope)\ndecision: WARN -- 1 proposed addition(s) share a name with a reverted entity but could not be confirmed -- a human verifies\ndigest(sha256): d07bc775aebbb2f2880b050b28f95a73dc235be59449c15a526e8df3cb107c68\n```\n\nExit 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`\n\ngives it exit 0 while the report text is identical (same digest, `d07bc775...`\n\n). 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.\n\nIf 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\"}]`\n\n, and treats a `table`\n\nthe same way it treats a qualifier parsed from SQL. That path blocks on `repo_dirty`\n\nexactly like the diff does.\n\n[@masondelan](https://dev.to/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`\n\nand 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`\n\nis 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.\n\nThere 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`\n\nwith taste, and the answer should be the same every time you ask. That is why the tool hashes its own output.\n\nThis is a spoke on the [pre-execution gate for AI agents](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/) cluster, and its object is the moment before a schema change commits. The neighbors ask adjacent questions:\n\n`revert_guard.py`\n\nstores 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.I would rather undersell this than have you wire it in as something it isn't.\n\n`amend`\n\nthat rewrote it out of history: invisible. Garbage history in, garbage gate out. It is exactly as good as your team's revert discipline.`ADD COLUMN`\n\nline, so a migration squash or a rename that merely says \"revert\" in passing can get logged as a revert of `users.card_token`\n\nand block a later honest add. It also keys off the revert `git log`\n\n/ `git show`\n\n. It never executes the diff, calls a model, or opens a socket.`2026-06-05`\n\n, the `c2ce7ed`\n\n, the hashes: all synthetic, from a repo I built for this post. The `card_token`\n\n/ PCI-DSS story and Selvedge belong to 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:\n\n``` bash\n$ python3 revert_guard.py proposed_card_token.diff --repo not_a_repo\nERROR: git rev-parse --is-inside-work-tree failed in not_a_repo: fatal: not a git repository (or any of the parent directories): .git\nbash\n$ python3 revert_guard.py ; echo \"exit=$?\"\nusage: revert_guard.py [-h] [--repo REPO] [--since SINCE]\n                       [--revert-pattern REVERT_PATTERN] [--warn-exit {0,1}]\n                       proposed\nexit=2\n```\n\nBoth 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`\n\nat 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.\n\nOn 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)`\n\nof 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...`\n\n, the killer BLOCK is `11007493...`\n\n, the WARN is `d07bc775...`\n\n, the unrelated SHIP is `7facc852...`\n\n. Absolute dates, not \"N days ago,\" precisely so tomorrow's run has the same hash as today's.\n\nThe 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`\n\n) the post prints.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"\nmake_fixtures.py -- builds the two real git repositories and the proposed-diff\nfiles that revert_guard.py runs on in the post. It only writes DATA and calls a\nLOCAL git to create commits; nothing here is executed by the guard.\n\nDeterminism: author/committer identity and every commit date are pinned, so a\nrebuild produces the same commit SHAs (and therefore the same short hashes) the\npost prints. Signing/hooks are disabled so a contributor's global git config\ncannot change the objects.\n\n  fixtures/repo_clean  -- init, add users.legacy_flag, REVERT legacy_flag, noise.\n                          One revert commit. Never touched card_token.\n  fixtures/repo_dirty  -- the same, PLUS: add users.card_token, REVERT card_token\n                          (message: \"PCI-DSS scope\"), noise. Two revert commits.\n  fixtures/proposed_card_token.diff        -- new migration 0042 + model field\n                                              adding users.card_token (qualified).\n  fixtures/proposed_card_token_model_only.diff -- only the model field (bare).\n  fixtures/proposed_last_login.diff        -- an unrelated, never-reverted column.\n  fixtures/proposed_card_token.entities.json -- the JSON-entity input form.\n  fixtures/not_a_repo/                     -- a plain dir for the bad-input case.\n\"\"\"\n\nimport os\nimport shutil\nimport subprocess\n\nBASE = os.path.dirname(os.path.abspath(__file__))\nFIX = os.path.join(BASE, \"fixtures\")\n\nENV = dict(os.environ)\nENV.update({\n    \"GIT_AUTHOR_NAME\": \"fixture\", \"GIT_AUTHOR_EMAIL\": \"fixture@example.invalid\",\n    \"GIT_COMMITTER_NAME\": \"fixture\", \"GIT_COMMITTER_EMAIL\": \"fixture@example.invalid\",\n})\n\nUSER_PY_BASE = '''\\\nfrom db import Base, Column, String, DateTime, Integer\n\nclass User(Base):\n    __tablename__ = \"users\"\n\n    id = Column(Integer, primary_key=True)\n    email = Column(String(255), nullable=False)\n    created_at = Column(DateTime, nullable=False)\n'''\n\nINIT_SQL = \"CREATE TABLE users (\\n  id INTEGER PRIMARY KEY,\\n  email TEXT NOT NULL,\\n  created_at TIMESTAMP NOT NULL\\n);\\n\"\n\ndef run(repo, args, date=None):\n    env = dict(ENV)\n    if date:\n        env[\"GIT_AUTHOR_DATE\"] = date\n        env[\"GIT_COMMITTER_DATE\"] = date\n    subprocess.run([\"git\", \"-c\", \"commit.gpgsign=false\", \"-c\", \"gc.auto=0\",\n                    \"-C\", repo] + args, env=env, check=True,\n                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n\ndef write(repo, rel, text):\n    path = os.path.join(repo, rel)\n    os.makedirs(os.path.dirname(path), exist_ok=True)\n    with open(path, \"w\") as fh:\n        fh.write(text)\n\ndef commit(repo, date, message):\n    run(repo, [\"add\", \"-A\"])\n    run(repo, [\"commit\", \"--no-verify\", \"-q\", \"-m\", message], date=date)\n\ndef init_repo(repo):\n    if os.path.exists(repo):\n        shutil.rmtree(repo)\n    os.makedirs(repo)\n    run(repo, [\"init\", \"-q\", \"-b\", \"main\"])\n\ndef add_field(repo, field, coltype):\n    text = USER_PY_BASE.rstrip(\"\\n\") + \"\\n    %s = Column(%s)\\n\" % (field, coltype)\n    write(repo, \"models/user.py\", text)\n\ndef base_history(repo):\n    # 1) init\n    write(repo, \"README.md\", \"# service\\n\\nInternal user service.\\n\")\n    write(repo, \"models/user.py\", USER_PY_BASE)\n    write(repo, \"migrations/0001_init.sql\", INIT_SQL)\n    commit(repo, \"2026-05-20T10:00:00\", \"init: user service skeleton\")\n    # 2) add users.legacy_flag\n    write(repo, \"migrations/0003_add_legacy_flag.sql\",\n          \"ALTER TABLE users ADD COLUMN legacy_flag BOOLEAN DEFAULT false;\\n\")\n    add_field(repo, \"legacy_flag\", \"String(8)\")\n    commit(repo, \"2026-05-22T09:30:00\", \"feat: add users.legacy_flag for v1 routing\")\n    # 3) REVERT legacy_flag (unrelated revert, shared by both repos)\n    os.remove(os.path.join(repo, \"migrations/0003_add_legacy_flag.sql\"))\n    write(repo, \"models/user.py\", USER_PY_BASE)\n    commit(repo, \"2026-05-24T14:15:00\",\n           \"revert: drop users.legacy_flag (unused after v2 launch)\")\n\ndef noise(repo, date):\n    write(repo, \"migrations/0009_add_orders_index.sql\",\n          \"CREATE INDEX idx_orders_user ON orders (user_id);\\n\")\n    write(repo, \"README.md\", \"# service\\n\\nInternal user service. See /docs.\\n\")\n    commit(repo, date, \"chore: index orders.user_id, docs pointer\")\n\ndef build_clean():\n    repo = os.path.join(FIX, \"repo_clean\")\n    init_repo(repo)\n    base_history(repo)\n    noise(repo, \"2026-06-20T11:00:00\")\n\ndef build_dirty():\n    repo = os.path.join(FIX, \"repo_dirty\")\n    init_repo(repo)\n    base_history(repo)\n    # 4) add users.card_token\n    write(repo, \"migrations/0007_add_card_token.sql\",\n          \"ALTER TABLE users ADD COLUMN card_token TEXT;\\n\")\n    add_field(repo, \"card_token\", \"String(255)\")\n    commit(repo, \"2026-06-03T13:20:00\",\n           \"feat: store users.card_token for one-click checkout\")\n    # 5) REVERT card_token -- the entity the killer demo re-proposes\n    os.remove(os.path.join(repo, \"migrations/0007_add_card_token.sql\"))\n    write(repo, \"models/user.py\", USER_PY_BASE)\n    commit(repo, \"2026-06-05T16:45:00\",\n           \"revert: drop users.card_token (PCI-DSS scope)\")\n    # 6) noise so the revert is not the latest commit\n    noise(repo, \"2026-06-20T11:00:00\")\n\nPROPOSED_CARD_TOKEN = '''\\\ndiff --git a/migrations/0042_add_card_token.sql b/migrations/0042_add_card_token.sql\nnew file mode 100644\nindex 0000000..a1a1a1a\n--- /dev/null\n+++ b/migrations/0042_add_card_token.sql\n@@ -0,0 +1,2 @@\n+-- store a tokenized card reference for the new checkout flow\n+ALTER TABLE users ADD COLUMN card_token TEXT;\ndiff --git a/models/user.py b/models/user.py\nindex b2b2b2b..c3c3c3c 100644\n--- a/models/user.py\n+++ b/models/user.py\n@@ -9,3 +9,4 @@ class User(Base):\n     email = Column(String(255), nullable=False)\n     created_at = Column(DateTime, nullable=False)\n+    card_token = Column(String(255))\n'''\n\nPROPOSED_MODEL_ONLY = '''\\\ndiff --git a/models/user.py b/models/user.py\nindex b2b2b2b..d4d4d4d 100644\n--- a/models/user.py\n+++ b/models/user.py\n@@ -9,3 +9,4 @@ class User(Base):\n     email = Column(String(255), nullable=False)\n     created_at = Column(DateTime, nullable=False)\n+    card_token = Column(String(255))\n'''\n\nPROPOSED_LAST_LOGIN = '''\\\ndiff --git a/migrations/0043_add_last_login.sql b/migrations/0043_add_last_login.sql\nnew file mode 100644\nindex 0000000..e5e5e5e\n--- /dev/null\n+++ b/migrations/0043_add_last_login.sql\n@@ -0,0 +1,1 @@\n+ALTER TABLE users ADD COLUMN last_login TIMESTAMP;\n'''\n\nPROPOSED_JSON = '''\\\n[\n  {\"name\": \"card_token\", \"table\": \"users\", \"kind\": \"column\"}\n]\n'''\n\ndef build_proposed():\n    with open(os.path.join(FIX, \"proposed_card_token.diff\"), \"w\") as fh:\n        fh.write(PROPOSED_CARD_TOKEN)\n    with open(os.path.join(FIX, \"proposed_card_token_model_only.diff\"), \"w\") as fh:\n        fh.write(PROPOSED_MODEL_ONLY)\n    with open(os.path.join(FIX, \"proposed_last_login.diff\"), \"w\") as fh:\n        fh.write(PROPOSED_LAST_LOGIN)\n    with open(os.path.join(FIX, \"proposed_card_token.entities.json\"), \"w\") as fh:\n        fh.write(PROPOSED_JSON)\n    nar = os.path.join(FIX, \"not_a_repo\")\n    os.makedirs(nar, exist_ok=True)\n    with open(os.path.join(nar, \"hello.txt\"), \"w\") as fh:\n        fh.write(\"this directory is deliberately not a git repo\\n\")\n\ndef main():\n    os.makedirs(FIX, exist_ok=True)\n    build_clean()\n    build_dirty()\n    build_proposed()\n    print(\"fixtures built in %s\" % FIX)\n\nif __name__ == \"__main__\":\n    main()\n```\n\nHere 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.\n\nIf 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.", "url": "https://wpnews.pro/news/your-ai-agent-re-adds-code-you-reverted-last-month", "canonical_source": "https://dev.to/alex_spinov/your-ai-agent-re-adds-code-you-reverted-last-month-3aa2", "published_at": "2026-07-09 04:24:13+00:00", "updated_at": "2026-07-09 04:41:13.862424+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety"], "entities": ["revert_guard.py", "Claude Code", "Selvedge", "masondelan", "Dev.to", "PCI-DSS"], "alternates": {"html": "https://wpnews.pro/news/your-ai-agent-re-adds-code-you-reverted-last-month", "markdown": "https://wpnews.pro/news/your-ai-agent-re-adds-code-you-reverted-last-month.md", "text": "https://wpnews.pro/news/your-ai-agent-re-adds-code-you-reverted-last-month.txt", "jsonld": "https://wpnews.pro/news/your-ai-agent-re-adds-code-you-reverted-last-month.jsonld"}}