AI Agents Drift Constantly. Gates Are What Fix It.

Three governance releases in one day, and a chat assistant that lost its own history five times. Same failure, two costumes.

AI Agents Drift Constantly. Gates Are What Fix It.

I started at 7:35 in the morning. It was 8:13 at night before I was done. In between, an open source protocol I maintain shipped four releases, one of which turned out to be the biggest change it has had since it existed. In a completely separate chat window, a personal AI assistant of mine lost half our conversation, five or six times. Same underlying problem, two very different symptoms, one very long day.

I maintain an open source project called AAHP: a handoff protocol for AI agents. The idea is simple. When one AI session ends and another picks up the same work, there needs to be a reliable, checkable record of what was done, what is left, and what is actually true right now. Without that record, agents drift. They forget decisions, contradict earlier work, or restate something as fact that was never actually verified.

Four releases in one day

The releases landed inside about seven hours, from mid morning to mid afternoon.

  • v3.6.0. shipped a doctor command: a conformance check that prints a machine readable pass or fail record, plus a set of gates any project can opt into for version sync, changelog format, and changelog presence.
  • v3.6.1, the same day. closed a real command injection vulnerability in one of those gates, found by the project's own adversarial review before it could be exploited anywhere else.
  • v3.7.0. added anti-entropy gates: a pattern scanner that bans configured strings project wide (it bans em dashes, a rule I hold to on this blog too), a check that an enum stays identical everywhere it is documented, and a check that every internal doc link actually resolves. Plus a short file of non-negotiable rules and a decision log, so nobody re-argues a settled question six months later.
  • v3.8.0, "Portable Governance." the release that took the rest of the day, and the one this article is really about.

The v3.6.1 fix is the part I keep coming back to. The vulnerable code ran an arbitrary config value straight through a shell:

// v3.6.0: an attacker-editable config value hits a shell
execSync(config.floorCmd)

// v3.6.1: fixed to a repo-relative script, no shell
execFileSync(resolvedScriptPath)

That is a real hole, in a release that had shipped a few hours earlier, closed the same day because the review process was built to look for exactly that kind of thing.

The proposal that caught itself lying

Before v3.8.0 had a single line of code, a proposal for it landed as issue #40. The goal: make the v3.7.0 governance gates adoptable by any other repo in about fifteen minutes, instead of the one to two hours it took at the time.

What stood out was not the feature list. It was that the proposal documented its own adversarial review, run before implementation started. That review found five direct contradictions between drafts that had been written independently:

  • Two definitions of the same command. one draft wanted it to run three gates, another wanted eight.
  • Two shapes for the same config option. incompatible field names for the identical setting.
  • A schema field with no consumer. a config key nothing in the code ever reads.
  • Two competing entry points. the same governance check reachable two different, conflicting ways.
  • Three ways to scaffold the same setup. three separate mechanisms for one init flow.

Every one of those would have shipped as working looking code if nobody had gone looking for the seams.

What v3.8.0 actually shipped

By 18:02 that evening, aahp check existed: one Node native command that runs all eight config-driven gates (changelog, changelog format, version sync, claims, forbidden patterns, schema doc sync, doc links, handoff) in a single pass, keeps going even if one gate fails, and exits non-zero only if something actually failed. A --json flag emits a versioned machine readable record, --quiet prints failures only, and config.check.only or config.check.skip let a repo choose exactly which gates apply to it. Point it at a repo with no config at all and every gate skips cleanly, no false red.

Next to it, aahp doctor --governance runs the existing conformance check but forces the three handoff-specific gates to skip without even evaluating them, so a repo with no .ai/handoff/ directory can still get a green conformance record for the parts of AAHP it actually uses. The default mode of doctor is byte-identical to before; governance mode is purely additive.

Then aahp init --gates scaffolds a governance-only setup: it writes aahp.config.json, adds a govern npm script, and drops in a ready-to-run GitHub Actions workflow, all without ever touching .ai/handoff/. That workflow, aahp-govern.yml, is opt-in, verify-only, and calls aahp check and aahp doctor --governance through npx --no-install, so there is no vendored script path to keep in sync.

Two smaller fixes did the quiet work underneath. The pinned-dependency gate used to hard fail for any repo consuming AAHP through npx, whether that repo cared or not. It is now config-driven through a pinnedDep object with name, location, and allowRange, so it is simply absent, and skipped, unless a repo opts in. And two of the pattern-scanning gates had a vacuous-pass bug: run outside a git work tree, they silently scanned zero files and reported success. They now share a git ls-files based helper that fails loud instead, the kind of bug that looks fine in every test until the one environment where it matters.

Put together, that fifteen-minute adoption number from the original proposal held up. The gates that took an afternoon to wire into a second repo before now take one command and one workflow file.

Six decisions, five contradictions

The project's README keeps a running architectural decision log, and v3.8.0 added six new entries, numbered ADR-011 through ADR-016. Read next to the original five contradictions from issue #40, they read less like new ideas and more like a settlement.

aahp check being the one aggregator, kept distinct from verify (handoff drift) and doctor (a conformance record), and aahp-govern.yml staying separate from the existing aahp-verify.yml, close out the "two competing entry points" and "three ways to scaffold" contradictions. Making the pinned-dependency gate opt-in through a single pinnedDep config shape closes the "two shapes for the same config option" contradiction, and incidentally the "schema field with no consumer" one too, since the field now has exactly one consumer. The doctor-records / check-runs split closes "two definitions of the same command." The git hooks resolving a vendored script first and falling back to the installed CLI closes the last scaffolding ambiguity. One decision, ADR-014, forcing gates to fail loud outside a git work tree, was not a resolution of a contradiction at all. It was a plain correctness bug the earlier review process happened to surface anyway.

Three layers of review, three different bugs

What actually convinced me the process works was watching it catch different problems at different stages, none of which overlapped with the others.

The first layer was the original issue #40 review, before any code existed, and it found the five structural contradictions above. The second layer showed up mid implementation: a preflight check flagged what looked like a stale, unwired gate reference. I went and checked it against the actual code. It was a false positive, the wiring was already correct, which was itself a small confirmation that the earlier structural fixes had held.

The third layer came from an automated code review on the pull request itself, and it found two things neither of the first two layers had any reason to catch, because they were not structural at all, they were plain robustness gaps. The new init --gates command wrote files to disk, writeFileSync, mkdirSync, copyFileSync, with no error handling, so a permissions problem would have surfaced as a raw stack trace instead of a message. And the new check command's use of spawnSync did not distinguish a gate that failed to even start from one that ran and failed, or one that was killed by a signal. Both were fixed before the release went out:

const r = spawnSync(process.execPath, [gateScript, ...gate.args, targetPath], { encoding: 'utf8' })
if (r.error) {
  // The gate process could not be spawned at all (e.g. missing interpreter).
  status = 'fail'
  reason = `failed to run gate: ${r.error.message}`
} else if (r.status === 0) {
  status = 'pass'
  reason = firstLine(r.stdout)
} else {
  // Non-zero exit, or r.status === null when the gate was killed by a signal.
  status = 'fail'
  reason = r.signal ? `gate killed by signal ${r.signal}` : firstLine(r.stderr || r.stdout)
}

At release, the full test suite stood at 219 of 222 passing, the three red ones being pre-existing Windows-only flakes that are green on Linux CI, alongside three brand new suites covering the release's new surface area, all fully green. None of that testing caught the two robustness gaps above. A different kind of review did. That is the actual point: no single layer is enough on its own, and the value is in stacking layers that look for different things.

The messier half of the day

In a different session that same day, running a personal AI assistant on a different system, I hit something far less elegant.

  • The restart loop. A restart interrupted a conversation mid task. Then it happened again, and again: five or six times over a few hours. Each one wiped a chunk of chat history.
  • The confident wrong answer. I asked which model I was talking to. It told me, with total confidence, that a specific model I use every day simply did not exist in its setup.
A model like that is not in my configuration.

It had not checked. It had guessed from a stale list in its own head, and stated the guess as a fact.

The fixes that actually worked were unglamorous:

  • Batch the writes. every config change goes into a single write instead of several, because each write to that file triggers a restart, and every restart risks losing the conversation.
  • Verify before claiming. check a fact by reading the file, not by trusting what the session remembers saying.
  • Label a guess as a guess. state plainly when something is unverified, instead of presenting it as settled.

Same disease, two symptoms

Both stories are the same failure wearing different clothes. Agents forget. They restart without carrying the full picture forward. They state things with total confidence that turn out to be wrong, not because the underlying model is unreliable in some deep sense, but because nothing forced a check before it spoke.

The fix in both cases was not a smarter model. It was structure: a gate that fails loud instead of passing quietly, a review process built to hunt for contradictions and bugs on purpose, layered so different stages catch different failure modes, and a habit of rereading the actual file before repeating a claim about it.

What I would tell anyone running agents day to day

If you run AI agents for anything that matters, code, operations, or your own workflow, build the thing that catches the lie before it ships. That can be a CI gate that fails on a contradiction. It can be a second, independently timed review that looks for a completely different class of bug than the first one did. It can be as small as a personal rule: never restate a fact from memory when the source is five seconds away.

Either way the discipline is the same. Assume drift is already happening, and go looking for it in more than one place, instead of waiting for it to surface on its own.

If you want to see the gates themselves, AAHP is open source: github.com/homeofe/AAHP. The proposal with the five contradictions, and the release that closed all of them, is issue #40, open for anyone to read.