Letting Agents Soft-Approve Merges Without Losing the Audit Trail
Almost everything, but not the yes
In an earlier post I argued that the model never approves; humans do. I still hold that line. What I didn’t cover is the part that makes it livable: if the agent does the pushing, the opening, the waiting and the retrying, what’s left for the person is one small act. That act has to be cheap to perform and impossible for the agent to fake.
Getting there took a few moving parts, and the interesting design choice is that none of them is “trust the agent to behave.” Each one is a mechanism that holds whether the agent behaves or not.
”Soft approve” is arming, not approving
In uBixShepherd, each repo declares two autonomy settings: autonomy.push and autonomy.merge. Each takes one of agent, human or shepherd, and push defaults to human, meaning the agent commits and somebody else pushes.
Set push to agent and it can push its own lane’s branch and open the merge request. Set merge to agent and it can do exactly one more thing: arm merge-when-pipeline-succeeds on its own MR. That’s the whole grant. It can’t merge directly, and every rule the forge enforces still applies: required pipelines, approvals, unresolved threads.
I call that a soft approve because it expresses intent (“this is ready, merge it when it’s allowed”) without claiming authority. The agent says I think this is done. It has not said this is good. Those are different statements, and keeping them different is most of the work.
Make the sign-off a failing check, not a convention
A rule like “agents never approve their own work” is easy to write down and easy to erode. So I didn’t leave it as a rule. The pipeline has a job, require-approval, that fails until a designated person has either approved the MR or given it a 👍.
Combine that with “pipelines must succeed” and the arming becomes safe. The agent arms the merge early; the merge sits there; the job is red; nothing happens until the person acts. No amount of agent enthusiasm gets past a failing check.
I watched this work, unprompted. An agent’s MR went green on every check, stopped at that one job, and the agent reported, correctly, that it couldn’t approve its own MR and the change was waiting on a person. That’s the behaviour I wanted, and it came from the pipeline rather than from the agent’s good manners.
Two details matter. The job fails closed: no token, an unreachable API, a malformed reply, a pipeline that isn’t an MR pipeline, all fail with the reason rather than pass. And “who counts” is explicit. When every MR is opened under the maintainer’s own account, author and approver are the same person, so the job takes a list of owners whose sign-off counts and logs a self sign-off in the job output when that’s what happened. The trail says so rather than hiding it.
The rough edge: approval doesn’t re-run the pipeline
GitLab does not re-run a pipeline when someone approves. The person approves, and the job stays red until it’s retried. Forgetting that step is the single most common way to stand in front of a perfectly approved MR wondering why it won’t merge.
I solved that in uBixOps, the CI tooling around the platform. It listens for approval events and for 👍 awards on a merge request, and retries the sign-off job for me. A few cases took care:
- An approval doesn’t retry a job that has already passed.
- A manual job that hasn’t run is played, not retried.
- An un-approve, or a revoked 👍, still triggers a retry, so the verdict can turn red again.
That last one is the audit-trail instinct showing up in tooling: a sign-off the person takes back should be as visible as one they give.
I’ll be honest about the limit, because the script’s own header is. This is a guard against merging in haste, not a security boundary. A click is cheap, and anyone holding an owner’s credentials can make it. What it buys is not secrecy; it’s that the yes is deliberate, attributable and separate.
The same rule applies to questions
Merges aren’t the only place an agent might be tempted to answer for the person. Shepherd’s agents hold questions as decisions: they raise one, give options and a recommendation, and stop. The tool that records the answer, decision_answer, takes only the person’s words. An agent can’t use it to answer its own question, and it never gives the 👍 or resolves an AI-review thread either.
It’s the same shape as the merge gate. The agent can prepare, propose and wait. The person’s input is a distinct step that the system won’t synthesise.
Where the trail lives
Because each yes is a separate act on a system of record, the audit trail isn’t something I assemble afterwards. It already exists, in four places that agree:
- The merge request: who opened it, every thread, and the approval or 👍 with the name it came from.
- The pipeline: which checks ran, which failed, which job was retried and when.
- Shepherd’s run records: what the agent did, in which lane, at what cost.
- Shepherd’s decisions: each question raised, the options offered, and the person’s own words in reply.
If you can’t answer “who said yes to this, and when?” from the MR alone, the process is already leaking. Here you can, and so can anyone who comes after me.
What I took from it
The lesson is a design one, not a prompt one. Let the agent do everything up to the merge, but make the human’s yes a separate, recorded act that the pipeline itself refuses to proceed without.
Don’t ask the agent to hold back. Give it a path where holding back is the only thing that works, make the person’s step one click, and let the systems you already use keep the receipts.