Verifying AI-Written Code: A Playbook
The agent finishes and reports back: "Refactored the settlement path, extracted the retry logic, behavior unchanged." The diff is clean. The tests are green. You merge it. Three weeks later a payment double-fires in production, and the postmortem lands on a guard clause that used to sit inside a try and now sits outside it. The change compiled. The suite passed. Nothing you had would have caught it, because you were never testing that boundary in the first place.
"The tests pass" is a real signal, but it answers a narrower question than teams give it credit for: the code you thought to test still does what you asserted. It says nothing about the code you didn't test, and an agent rewriting a function does not confine its edits to the paths your suite happens to exercise. A passing suite proves the assertions held. It does not prove the change the agent actually made preserved behavior.
Verification asks that second, harder question directly. Given two versions of a function — before and after the agent touched it — did the contract survive? Did the control flow keep its shape? Did a cleanup path quietly disappear? And, most importantly, it is built to answer "I could not tell" out loud, as a distinct verdict from "yes." A verifier that always says looks equivalent is worse than no verifier at all, because teams learn to trust it and then it is wrong once.
If you want the whole discipline in one sentence: classify what the change is, verify whether it preserved behavior, and gate the merge on the evidence — while never painting "unknown" green. act101 implements that as a family of differential verification tools an agent runs against its own diff, each returning a three-valued verdict that discloses exactly which dimensions it was able to judge.
Why verifying agent-written change needs its own discipline
Review and testing were designed around a human writing code at human speed. Both assumptions break when an agent writes the change.
- The agent has no memory of intent. A fresh session sees the function in front of it. It does not remember that the
finallyblock was load-bearing, or that the early return existed to short-circuit a re-entrancy path. It optimizes for a clean-looking diff, not for the invariant nobody wrote down. - A green suite is an incomplete oracle. Tests cover the behavior someone anticipated. The agent's edit can preserve every tested path and still move an untested one. Coverage tells you what was checked; it cannot vouch for what wasn't.
- "Looks the same" is not the same. A renamed variable and a moved guard clause can produce diffs of identical size. The eye scanning a large pull request is measuring text, and text similarity is a poor proxy for behavioral similarity.
- Ports have no shared oracle at all. When COBOL becomes Java or C++ becomes Rust, the languages don't share a test suite, a type system, or even a notion of what an exception is. "It compiles and the new tests pass" says nothing about whether the source's behavior crossed the boundary intact.
- Volume defeats manual scrutiny. One careful reviewer can hold one function's before-and-after in their head. They cannot do it for forty functions across an afternoon of agent output, which is now an ordinary afternoon.
- The agent is not a trustworthy witness to itself. Asking the model "did you preserve behavior?" gets you a fluent, confident yes generated by the same process that made the change. Self-attestation is not evidence.
The old backstop was a senior engineer reading every meaningful diff. That was already the bottleneck when humans wrote every line. It cannot scale to code generated all day by a process that starts each session with a clean memory and a bias toward looking finished.
The new backstop is differential and structural. Take the two versions, compare their modeled behavior directly, and return a verdict that says what it checked and what it couldn't. The verifier is the skeptical reviewer the agent's own confidence can't replace.
The verification loop, end to end
agent edits ─► classify ─► verify ─► gate ─► merge
▲ (what (did it (is it
│ changed?) survive?) safe?)
└──────── remediate the named divergence ────────┘
There are several tools in the loop, but only three operating stages.
- Classify the change. For each touched function, decide whether the hunk is format, signature, or behavior. Most edits are not behavior changes; naming them cheaply lets you spend scrutiny where it belongs.
- Verify preservation. For the changes that matter, compare the two versions dimension by dimension — contract, control-flow shape, side effects, cross-language parity — and get a Preserved / Diverged / Unknown verdict per function.
- Gate the merge. Combine each verdict with whether a test actually reaches the change, and produce one decision: Merge, Review, Block, or Unknown — with Unknown held distinct from Merge so "we couldn't judge this" never masquerades as "this is fine."
The back-edge is the point. A blocking verdict does not say "behavior bad" and hand a human a wall of diff. It names the function and the dimension that moved — raises count 1 → 0, dropped cleanup, branch shape changed with no test reaching it — so the engineer or the agent can repair that specific boundary and re-run the same check. The evidence survives the session as a receipt; the correction re-enters the loop.
This sits beside tests and review rather than replacing them. Tests assert intended behavior on the paths you chose. Review judges whether the change is a good idea. Verification answers a third question neither was built for: did this edit preserve the behavior that was already there? Keep the three separate and each stays trustworthy.
Stage 1 · classify the change
Not every diff deserves the same suspicion. The cheapest useful move is to sort each function's change into a kind, so a reviewer — human or agent — knows where to look before looking.
verify_diff_semantics takes two versions of a file and classifies each changed function's hunks as one of:
- Format — the syntax tree is equal but the text differs. Reflow, comments, a local variable renamed with no effect on modeled behavior. Nothing downstream depends on it.
- Signature — the interface moved: parameter arity, return shape. Callers may care even when the body is untouched.
- Behavior — the control-flow graph, effects, guards, or raises changed. This is the class that earns a real look.
The classification runs on model diffs, not string diffs: signature via an interface comparison, behavior via a control-flow and effect comparison, format via "AST-equal but text-different." A local rename that a text diff would flag as a large change collapses to Format, because nothing about the function's modeled behavior moved. That is the entire value — it stops the eye from spending its budget on reflow and points it at the one hunk that changed a branch.
For a whole pull request, summarize_pr aggregates the per-function classifications across every touched symbol into counts: how many signature changes, how many behavior changes, how many format-only. A PR that is forty format-only hunks and one behavior change is a very different review than its line count suggests, and the summary makes that legible before anyone opens the diff.
Classification is triage, not judgment. Format-only is a strong hint a hunk is safe; it is not yet a proof that behavior survived. It tells you where the next stage needs to run, not that the next stage can be skipped.
Stage 2 · verify preservation
Once you know a function's behavior changed — or you simply want proof it didn't — the differential verifiers compare the two versions directly. Each returns a three-valued verdict and, on every result, a modeled_kinds field that discloses which dimensions it was actually able to judge for the grammars in play.
verify_contract_preserved checks a function's public and behavioral contract across the two versions: signature arity, return shape, the set of side effects, control-flow shape, and raises. The verdict is preserved, broken{dimensions}, or unknown{dimensions}. The rule that makes it trustworthy: it never reports preserved on a dimension the grammar does not model. If the language's control-flow modeling can't see a branch construct, that dimension comes back unknown, not a quiet pass.
verify_behavioral_equivalence compares the two versions' control-flow graphs — the count and shape of branch, loop, exception, and return edges — and returns equivalent, changed{dimensions}, or unknown{reason}. Same branch-and-loop skeleton, same modeled behavior. This is the tool an agent runs after an extract-function or an inline to prove it moved code without moving behavior.
verify_side_effects diffs the effect signature between versions: which reads, writes, allocations, and blocking calls were added or removed. It flags one class by name — dropped cleanup: an allocation still happens on both sides, but a matching write or blocking teardown was removed. That is the shape of the classic leak the agent introduces while "simplifying" a resource path, and it is exactly the pattern a green test suite sails straight past.
verify_port_parity is the cross-language verifier. It compares a source symbol and its port dimension by dimension — signature arity, return presence, effects, control-flow shape, raises — and, crucially, judges a dimension only when both grammars model it. An effect kind one language expresses and the other doesn't is excluded from the comparison rather than reported as a divergence. It will only return preserved when at least two dimensions were jointly modeled and matched; a matched signature alone is not parity evidence and comes back unknown. A single real divergence dominates and yields diverged.
Port parity has an optional execution tier for the languages where it is safe to run untrusted-to-you-but-yours code: for TypeScript/JavaScript (node) and Python (python3), when the runtime is present and the signature is JSON-representable, it can drive both versions with a handful of deterministic inputs and diff the outputs, upgrading a structural verdict to an observed one. It runs each side under a resource boundary — CPU, memory, and file-size caps — and, if any input's output diverges, forces the verdict to diverged. Two honesty rules govern it: this is a resource boundary, not a security sandbox (there is no network isolation, and it is scoped to your own source and ported code, not adversarial third-party code), and it never claims execution parity it did not actually run. Any eligibility gap — a compiled language, a missing runtime, a signature that won't serialize — falls back cleanly to the structural verdict with the reason recorded.
The through-line across all four is the same: the verdict distinguishes preserved from diverged from I couldn't judge this dimension, and it tells you which was which. Structural verification is deliberately not compiler-grade — it compares the shape of control flow and effects, not alias-aware execution paths or precise exception targets. It would rather return unknown than launder a guess into a green check.
Stage 3 · gate the merge
Classification and per-function verdicts are inputs. A merge needs one decision.
Two tools produce it. verify_test_impact finds the minimal set of tests whose call graph transitively reaches the changed symbols — the honest answer to "is this change actually covered?", derived from the call graph rather than from a coverage report's optimism. It walks from the change outward, and where it cannot resolve a callee or hits its depth cap, it says so rather than pretending the frontier is empty.
The gate then composes the change class, the test reach, and the effect findings into a single verdict by a first-match rule set:
- Dropped cleanup anywhere → Block. An allocation kept while its teardown was removed is a regression regardless of coverage.
- A behavior or signature change with no test reaching it → Block. The riskiest combination is an unwitnessed behavior change; do not merge it on faith.
- A behavior change that is tested → Review. A human should look, but the safety net exists.
- A signature change (callers updated, body intact) → Merge.
- Format-only → Merge.
- Tier-blocked, analysis failed, or the grammar can't model the construct → Unknown.
The verdicts carry an exit contract for CI: Merge = 0, Block = 1, Review = 2, Unknown = 3. Read that carefully — the codes are deliberately not ordered so that "anything nonzero is failure." Review and Unknown are their own states with their own numbers, and a CI script must branch on the exact code, not on != 0. The overall verdict for a pull request is the most severe verdict across all its changed functions: one blocked function blocks the PR.
Exit 3 must never be painted green. "We could not judge this change" and "this change preserved behavior" are different statements, and collapsing them is how a verification gate quietly becomes theater. Unknown is a request for a human or a better-modeled grammar, not a pass.
Because the whole surface is exposed to the agent through MCP, the agent runs this stage on itself before it ever reaches CI. A model that finishes an extraction can classify its own hunks, verify the contract held, check whether a test reaches the change, and read the gate verdict — all before claiming the work is done. CI stays the backstop, not the first place anyone learns a new divergence appeared.
The agent does not get to certify its own diff by describing it. "I preserved the behavior" is a claim generated by the thing that made the change. "Contract preserved, CFG equivalent, dropped-cleanup false, one test reaches it, gate: Merge" is a receipt. Insist on the receipt.
The merge gate
A verification verdict is one input to a merge decision, not a substitute for tests, review, or security gates. The minimum that makes this input trustworthy:
- The comparison is real. Both versions parse and the target function is found in each. A verifier that couldn't build one side returns Unknown, never a pass.
- The verdict names its dimensions. Every result carries
modeled_kinds. Apreservedthat silently skipped the dimension you cared about is not the reassurance it looks like — read what was judged. - Unknown stays its own state. Tier denial, analysis failure, and unmodeled constructs surface as Unknown and are triaged, not swept into the pass bucket by a
!= 0check. - Test reach is derived, not assumed. "Covered" means a test's call graph reaches the change, not that overall coverage is high somewhere else in the file.
- Every block carries before → after evidence. A failing gate names the function and the dimension that moved. That is what an engineer acts on; a bare red X is what they learn to override.
- Port parity claims match what ran. A structural parity verdict says structural; an execution-enriched one says execution. The two are labeled differently because they prove different things.
- The receipt survives the job. If verification ran, its evidence is persisted against the function's before/after hashes so a later reviewer can trust it without re-running — and discard it the moment the code changes underneath it.
A pull request green on these conditions has preserved the behavioral dimensions the verifier could model. It may still be a bad feature, a security regression, or a behavior change on a dimension no grammar models yet. Keep the claim narrow. Narrow claims are the ones teams keep trusting.
Best practices, in plain English
- Classify before you verify. Run diff semantics first so scrutiny lands on the behavior hunks and format-only churn stops consuming review budget.
- Verify the change, test the intent. Verification proves this edit preserved prior behavior; tests assert desired behavior. Keep writing tests — they are the oracle verification leans on at the gate.
- Treat Unknown as a task, not a nuisance. An Unknown verdict is the tool refusing to guess. Route it to a human or a better-modeled path; do not add a rule that recolors it green.
- Block on dropped cleanup without debate. It is the highest-signal, lowest-false-positive finding in the set. An allocation that kept its acquire and lost its release is a regression every time.
- Let the agent gate itself before the push. The cheapest divergence to fix is the one the model catches in its own session. CI is the backstop, not the discovery point.
- Name the CI codes explicitly. Write out Merge/Review/Block/Unknown handling in the workflow. A blanket "fail on nonzero" turns Review and Unknown into blocks and teaches the team to override the gate.
- Keep port-parity honesty visible. When a parity verdict is structural, say structural. Reserve "outputs matched" for runs that actually executed, on the languages where execution is eligible.
- Persist receipts, and re-validate them. A receipt is evidence only while the before/after hashes still match the live code. Trust a cached verdict only after confirming the span hasn't moved under it.
- Read
modeled_kinds, not just the headline. Apreservedverdict that skipped the dimension you care about is a narrower statement than it appears. The disclosure is there so you can tell. - Bisect divergence, don't theorize about it. When behavior moved and you don't know when, walk the history for the commit that introduced the behavior hunk. The tool points at the commit; git shows the change.
Failure modes & gotchas
These are the ways a verification gate turns into decoration — or worse, a confident false pass.
- The green-suite illusion. "All tests pass, so the refactor is safe." Fix: the suite proves the paths it covers; verification proves the change preserved behavior. Run both; conflate neither.
- The Unknown-as-pass slide. CI checks
exit != 0, so Unknown (3) reads as failure — or a well-meaning tweak maps Unknown to 0 and it reads as pass. Fix: branch on the exact code; keep Unknown a distinct, triaged state. - The self-attestation trap. The agent says it preserved behavior and the reviewer believes the prose. Fix: require the verdict and its
modeled_kinds, not the model's description of its own work. - The text-size mirage. A big diff gets heavy review and a one-line diff gets waved through, though the one-liner moved a guard. Fix: classify by model diff; let Behavior, not line count, set the scrutiny level.
- The parity overclaim. A structural port-parity
preservedgets reported as "outputs verified equal." Fix: label structural verdicts as structural; only execution-tier runs earned the word "outputs." - The dropped-cleanup blind spot. The agent "simplifies" a resource path, removes the release, keeps the acquire, and every test passes because the leak is invisible to output. Fix: block on the dropped-cleanup flag; it exists precisely for the case tests can't see.
- The unmodeled-dimension assumption. A grammar doesn't model a construct, the dimension returns Unknown, and someone reads the surrounding
preservedas covering it. Fix: readmodeled_kinds; an absent dimension is unproven, not preserved. - The stale receipt. A cached
Mergereceipt gets trusted after the function was edited again. Fix: receipts are content-addressed to before/after hashes — a mismatch means re-verify, not reuse. - The sandbox misread. Someone points the execution tier at third-party code expecting isolation. Fix: it is a resource boundary, not a security sandbox — scope it to your own source and ported code.
- The single-signature port. A cross-language verdict comes back
unknownbecause only the signature was jointly modeled, and a reader treats Unknown as "close enough." Fix: parity requires at least two jointly-modeled dimensions to saypreserved; one is not evidence.
The common thread is honesty. A verifier earns authority only when its preserved is narrow and provable, its Unknown is loud, and its evidence survives the diff it describes. The first time a team learns the gate said "equivalent" about a change that wasn't, they stop reading its verdicts — even after the gap is fixed.
Cost, and the agent budget
Structural verification is cheap where it matters. Comparing two versions of a function is a pair of analyses on a bounded span, not a whole-repository crawl, so an agent can verify each function it touches without turning a refactor into a batch job. Classification and contract checks are fast enough to run inline, per function, in the agent's own session.
The one stage with real cost is execution-tier port parity, because it spawns subprocesses. That cost is opt-in and bounded: it runs only for eligible interpreter languages with serializable signatures, only a handful of deterministic input cases, and only under CPU, memory, and file-size caps. Everywhere else, parity stays structural and free of a runtime. You pay for execution exactly where it buys you an observed verdict, and nowhere else.
For the agent, the valuable output is not the two function versions reread into context. It is a compact verdict: the change class, the preservation result, the deciding dimension, and whether a test reaches it. That compact receipt is what lets a model self-gate cheaply — it spends a few structural comparisons, not a re-ingestion of the codebase, to earn the right to say a change is done.
How act101 implements the loop
The discipline is describable without a product: take two versions, compare modeled behavior, and gate on a verdict that admits what it couldn't judge. The hard part is producing one comparable behavioral signature across languages, refactors, ports, agent sessions, and CI — and refusing to fake it when the inputs aren't comparable.
act101 builds these verdicts from the same structural analyzers it uses for the rest of its work:
- control-flow analysis supplies the branch/loop/exception/return shape behind equivalence and contract checks;
- effect analysis supplies the reads, writes, allocations, and blocking calls behind side-effect diffs and the dropped-cleanup class;
- the interface model supplies signature and return comparison;
- the call-graph engine plus test-file detection supplies test-impact;
- every result implements a model that carries
modeled_kinds, so what was judged is disclosed by construction rather than by convention.
The verification family is exposed through MCP as the agent's surface — verify_diff_semantics, verify_contract_preserved, verify_behavioral_equivalence, verify_side_effects, verify_test_impact, and summarize_pr — with bisect_regression to find the commit that introduced a behavior change (semantic bisect ships in Architecture Edition), and durable receipts as content-addressed evidence of a run. The verification family ships in Engineering Edition: the same tier that gives the agent deterministic refactor operations gives it the means to prove those refactors preserved behavior. Cross-language verify_port_parity, and behavioral equivalence run in cross-language port mode, ship in Enterprise Edition, alongside the porting state machine they exist to police — because a migration you cannot verify is a migration you cannot trust.
The verdict is evidence, not reassurance. Same two versions, same modeled dimensions in; same three-valued verdict out. When the versions aren't comparable, or a dimension isn't modeled, the tool returns Unknown instead of smoothing the gap into a prettier "equivalent."
The maturity ladder
Tick what the team does today, not what the rollout plan hopes for.
- [ ] Agents classify their own changes (format / signature / behavior) before reporting work done
- [ ] Behavior-class changes are verified against the prior version, not merged on a green suite alone
- [ ]
verify_side_effectsruns on resource-handling code and dropped-cleanup blocks the merge - [ ] Test-impact is derived from the call graph, not inferred from an overall coverage number
- [ ] The gate's four verdicts are handled explicitly in CI, with Unknown distinct from Merge
- [ ] Agents self-gate before the push; CI is the backstop, not the discovery point
- [ ] Port work is checked with
verify_port_parity, and structural vs execution verdicts are labeled honestly - [ ] Every merge-relevant verdict reads
modeled_kinds, so "preserved" is never over-read - [ ] Receipts persist and are re-validated against live before/after hashes before reuse
- [ ] Behavior regressions are located with
bisect_regression, then confirmed with git
Zero to three: verification by vibes and a green suite. Four to six: behavior changes get real scrutiny but the loop is manual. Seven to nine: the agent and CI both meet the same evidence. Ten: every change — refactor or port, human or agent — arrives at the merge with a verdict that says what it preserved and admits what it couldn't judge.
A reasonable 30 / 60 / 90-day plan
- Days 1–30 — classify and observe. Turn on diff-semantics classification and
summarize_prfor agent-authored pull requests. Do not block anything yet. Learn the ratio of format-to-behavior in your agent's output, and start readingmodeled_kindsso the team internalizes what "preserved" does and doesn't cover. - Days 31–60 — verify the behavior class. For every behavior-class change, run contract and equivalence checks, and run side-effect diffs on resource-handling paths. Have agents self-verify during refactors, still without CI authority. Begin blocking on dropped cleanup — it is the finding least likely to cost you a false positive while you build trust.
- Days 61–90 — gate with intent. Wire the four gate verdicts into CI, handling each explicitly and keeping Unknown its own state. Persist receipts. For teams doing migration work, add
verify_port_parityto the port pipeline and enable the execution tier where the languages qualify. Track every gate override as a policy bug, not as normal operation.
At ninety days the win is not a green board. It is a team that can say, of any agent-written change, this preserved behavior on the dimensions we can model, a test reaches it, and here is the receipt — and, just as valuably, can point at the changes where the honest answer was "we could not tell," and knows a human looked.
What differential testing got right (and what structural verification adds)
The idea underneath this is old and well-earned. Differential testing — run two versions on the same inputs, compare the outputs — has caught compiler bugs and validated rewrites for decades. Equivalence checking is a staple of hardware verification, where "the optimized circuit behaves like the reference" is a property you prove, not hope. The instinct is right: to trust a transformation, compare the thing before with the thing after and insist they match.
Those techniques assumed you could run both versions on shared inputs. That assumption holds for a compiler and a circuit. It frays for a function mid-refactor with no isolated harness, and it breaks entirely for a port where the two versions don't share a language, a runtime, or a test. What was missing was a way to compare modeled behavior structurally — control-flow shape, effects, contract, cross-language parity — when execution isn't available, and to be honest, dimension by dimension, about the limits of that model.
That is the axis structural verification adds. It compares what it can model directly, runs the code where running it is both possible and cheap, and returns a verdict that separates preserved from diverged from couldn't tell. The shortest version is the useful one: classify the change, verify it preserved behavior, gate the merge on the evidence — and when the evidence runs out, say so, rather than call it green. Do that on the cadence agents write code, and "the agent said it was fine" stops being the last word before production finds out otherwise.