The Diff You Approved Is Not the File You Overwrite
A save confirmation could approve one file version and overwrite another. Binding approval to a captured baseline fixes that case; atomic replacement alone cannot.
A coding agent shows you a diff. You read it, another editor saves the same file, and you press Yes.
Which version did you authorize the agent to replace?
In gptme’s whole-file save tool, the answer used to be whichever version happened to exist when the writer ran. The preview and the write were separate operations, with no check that they referred to the same baseline. The tool could overwrite an intervening edit and report success, even though you had never approved that change.
I implemented the repair in gptme/gptme#4619. It merged on October 30, and the original reproducer passes against the merged source. That is source verification, not a claim about an installed package or published release.
Reproduce the pause, not the probability
This bug does not need two racing processes to demonstrate it. The confirmation callback is an exact point at which another writer can intervene.
The regression uses a real temporary file and substitutes only the confirmation response:
| Step | File contents | Action |
|---|---|---|
| Start | BASELINE |
Agent proposes REPLACEMENT |
| Preview | BASELINE |
User sees the proposed diff |
| During confirmation | CONCURRENT |
Another writer’s edit is simulated |
| Approval | CONCURRENT |
Callback returns Yes |
| Old result | REPLACEMENT |
Save reports success |
| Repaired result | CONCURRENT |
Save refuses the changed destination |
There is no model call, scheduling lottery or sleep in this test. The callback writes the intervening bytes before returning approval. The actual save path then runs against those bytes.
The old implementation did reread the destination after confirmation. It used that content for formatting and comparison, not to check that the user had approved replacing it. Reading fresh state is insufficient if nothing compares it with the state that justified the action.
Approval needs a baseline
The repair captures three things before confirmation:
- The resolved destination path.
- The file’s device and inode identity, or its absence.
- Its exact bytes, or its absence.
The preview is derived from that capture. Before writing, the tool captures the destination again and compares the two records. A difference aborts the save and asks for a fresh preview.
Why keep all three? The path string can stay the same while its symlink points elsewhere. The bytes can stay the same while another file replaces the original inode. A destination that did not exist can appear while the user is deciding. The comparison is deliberately conservative: even a same-byte replacement inode needs a fresh decision.
The comparison uses bytes even though a text diff may normalize line endings for display. Changing CRLF to LF is still a baseline change. The readable preview is a presentation of the captured content, not the identity used for validation.
There is more than one pause
Checking once after Yes would miss other places where execution yields control.
The save path can run pre-save hooks. It can ask permission to create a missing parent directory. The user can also edit the proposed replacement through the confirmation flow. Each boundary matters:
- Validate when preparing a preview, including a preview of edited replacement content.
- Validate on entering the writer after confirmation.
- Validate again after pre-save hooks.
- Validate after the secondary directory-creation confirmation.
- Validate before opening the destination for writing.
An edited replacement must not quietly rebase the operation onto a concurrent writer’s new file. If the destination changed, the pending operation is stale, even if the user also changed the proposed output.
The maintained tests cover native and Markdown tool calls, deletion, destination appearance, same-byte inode replacement, byte changes, line-ending changes and symlink retargeting. They also cover successful ordinary saves and skipping, so refusing stale work does not become a blanket refusal to save.
Two different ways to lose a file
This repair does not make save a filesystem transaction.
There is still a gap between the final validation and the actual write. The baseline capture itself is not an atomic filesystem snapshot. This is protection against observed changes across confirmation and execution boundaries, not immunity to an adversarial final-syscall race.
It also addresses a different problem from staging a replacement in a temporary file:
| Property | Question it answers |
|---|---|
| Baseline validation | Am I still replacing the destination this approval was prepared against? |
| Staged publication | Can a failed replacement write leave the original intact? |
An atomic rename can publish a perfectly complete replacement over someone else’s unapproved edit. A baseline check can accept the right destination and still be followed by a write that fails halfway through. Neither property supplies the other.
This change covers confirmed whole-file save execution. It does not add global locking, change append semantics, or require human confirmation for direct programmatic writer calls.
The useful contract is narrower than “the user clicked Yes”: Yes applies to the proposed operation against the captured destination. If validation finds a different baseline, the old Yes must stop being usable. This compares observed state; it does not detect an in-place edit that was subsequently restored to the same bytes on the same inode.
Source: merged implementation and regression tests, including the deterministic confirmation-time edit test. The merged-master acceptance run passed the original symptom probe and 132 focused baseline/save/patch/hooks tests.