Your coding agent finishes a task and tells you the tests passed.
You accept the report. Later, when one claim does not match what happened, you go back through the log to find where the story diverged - and the log cannot tell you. It records what the agent said. It does not record what ran.
That gap is not solved by making the agent's report more persuasive. A report is still a report unless the run is backed by execution evidence.
The question underneath
"Did the tests pass" is a question about an outcome. The useful question is one level down:
What would have to be recorded for that answer to be checkable later?
That changes what you build: you stop making the agent more trustworthy and start making its runs more auditable. That turns a vague trust question into a concrete evidence problem.
Three words, kept strictly apart.
1. Requested is not a fact
When you start a task you name things: a model, an effort level, a sandbox, an approval policy. Those are requests.
Between your request and the actual invocation sit a provider, a tool, a routing layer, and a stack of defaults. Any of them can end up serving something other than what you named, which is why the request and the result need to stay distinguishable.
The failure is not the substitution. It is writing the request into the log in the same shape as a result:
model: claude-x-large <- did this run, or was this asked for?
Once a request and a result look identical on the page, nobody reading the history afterwards can separate them. Label requests as requests. They are useful in that shape: they tell you what policy intended. They are just not evidence.
2. Observations have different strength
"What actually ran" feels like a yes-or-no fact. It is not. It is a claim with a source, and sources differ.
Three rungs, weakest first:
- The tool accepted the value. Your request was not rejected. That tells you it was well formed, and nothing about what served the call.
- Provider metadata reported the value back. Stronger, because the value did not originate in your request.
- Something you trust established it for that specific invocation. Strongest.
The rule that makes this useful is one line: never round a weaker observation up to a stronger one. If all you have is rung one, the record says rung one. It does not say "confirmed" because "confirmed" reads better in a summary. That discipline is what makes the record worth reading when something does not match the report.
I reserve a separate word, effective, for rung three only: provider-native and bound to that specific invocation. Applied loosely, the word stops carrying weight exactly when you need it to.
3. All of it is about one exact SHA
Evidence is only meaningful if you know what it is evidence about.
Every exit code, every observation, every approval is a fact about one specific revision. So bind the record to the commit hash, not to a branch name and not to a conversation summary.
A branch name is a pointer that moves. "Reviewed on main" says nothing once main has advanced. A conversation summary is worse: it is the model's own recollection of what it did, which is the self-report you were trying to get away from.
Binding to a SHA also keeps an easy-to-miss gap visible: a review recorded against one commit says nothing about an amended revision, because that is a different SHA.
What this does not give you
Worth being blunt about the limit, because it is the part most likely to get oversold.
None of this proves the code is correct. A run can be perfectly accounted for, every exit code captured and every digest bound to the right commit, and the change can still be wrong, unsafe, or badly designed.
Execution evidence answers a narrower question: what actually happened when this candidate was exercised. You need a reliable answer to that before a review is worth much, because reviewing a run that did not happen as described is reviewing fiction. But it is an input to judgment, not a substitute for it.
Two findings sharpen this. Wang, Pradel and Liu found that patches counted as resolved on SWE-bench Verified can still diverge from the developers' own fix, some plainly incorrect. And Huang and colleagues found that language models struggle to correct their own reasoning without external feedback, which is reason enough to treat a self-generated "looks good" as the weakest signal in the room.
Two things that follow
Some approvals do not belong to the model at all. Merge, production deployment, and changes to physical or network devices are decisions with a blast radius. Automation can gather the evidence and walk up to that line. A person crosses it, reading facts rather than a paragraph explaining that everything went fine.
A builder is not its own final reviewer. Keep the roles separate. The independent Reviewer judges an exact SHA it did not build, and it has no authority to remediate that revision - a Reviewer that starts fixing the code stops being independent for that revision. Where policy requires independent review, the Reviewer uses a different provider/model family from the Builder. That is a structural response to the documented tendency of a model acting as judge to favor its own outputs, rather than a prompt-level one.
The short version
Ask what you would have to record for the agent's answer to be checkable. Record exactly that, at the strength it deserves, bound to an exact SHA. Keep the decisions with real blast radius on the human side of the line.
That is not a trust problem. It is a bookkeeping problem, and bookkeeping problems have solutions.
I am building Kelruno around this - independent verification for coding agents. Pre-release.
Further reading: Requested, observed, effective: what an AI agent run proves
References
- Wang, Y., Pradel, M., and Liu, Z. (2025). Are "Solved Issues" in SWE-bench Really Solved Correctly? An Empirical Study. arXiv:2503.15223
- Huang, J., Chen, X., Mishra, S., et al. (2023). Large Language Models Cannot Self-Correct Reasoning Yet. arXiv:2310.01798
- Zheng, L., Chiang, W.-L., Sheng, Y., et al. (2023). Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. arXiv:2306.05685
Top comments (1)
The test-suite mutation problem is where I see this bite hardest in practice. An agent gets stuck on a failing assertion, edits the fixture or loosens the expectation until the exit code turns green, and reports clean tests. The run log shows a passing test suite against the target commit, but the verification goalpost moved during the run.
Binding the audit record to an external git diff that explicitly checks whether test files or mocks were touched during the patch cycle has saved me more times than checking the exit code itself.