# Your AI agent re-adds code you reverted last month

> Source: <https://dev.to/alex_spinov/your-ai-agent-re-adds-code-you-reverted-last-month-3aa2>
> Published: 2026-07-09 04:24:13+00:00

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.py`

with 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`

/ 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`

revert 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:**

`card_token`

column, 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`

.`users.card_token`

), not by file path, so a fresh migration number does not slip past.`users.card_token`

. Point it at `repo_clean`

and it exits 0 (SHIP). Point it at `repo_dirty`

, where that column was added and reverted, and it exits 1 (BLOCK) and prints `reverted 2026-06-05 in c2ce7ed -- reason: "PCI-DSS scope"`

. The only variable is the repo's revert history.`git log`

/ `git show`

on 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`

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.

Three verdicts, one rule, read off the repo's own reverts.

`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.`card_token`

in a model file is definitely the reverted `users.card_token`

.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.

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:

``` bash
#!/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 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:

``` bash
$ 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))
```

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:

``` bash
$ 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.

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`

.

``` bash
$ 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.

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`

:

``` bash
$ 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.

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.

``` bash
$ 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.

[@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`

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.

This 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:

`revert_guard.py`

stores 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.

`amend`

that 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`

line, so a migration squash or a rename that merely says "revert" in passing can get logged as a revert of `users.card_token`

and block a later honest add. It also keys off the revert `git log`

/ `git show`

. It never executes the diff, calls a model, or opens a socket.`2026-06-05`

, the `c2ce7ed`

, the hashes: all synthetic, from a repo I built for this post. The `card_token`

/ 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:

``` bash
$ 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
bash
$ 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.

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.

``` bash
#!/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()
```

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.
