Octofs 0.9.0: Line 42 Is a Lie | Muvon
Octofs 0.9.0: Line 42 Is a Lie
The agent asked to replace lines 40 through 44. It got lines 40 through 44. They were not the lines it had read.
Nothing crashed. No error surfaced. Somewhere between the view that produced the plan and the batch_edit that executed it, a formatter had run and shifted the file down by three lines — and the edit landed cleanly on five perfectly innocent lines of code. The model saw a success response, reported the refactor as done, and moved on. We found it in review, twenty minutes later, by reading a diff that made no sense.
That's the failure mode this release exists to kill.
0.9.0 makes every line address content-verified: a line is N:hh — its position plus a hash of what's on it — and every edit tool checks the hash against the file before it writes a byte. A stale target now fails loudly, with the current content in the error. It cannot land on the wrong line, because "the wrong line" no longer matches.
Two other changes shipped alongside it, and they turn out to be the same idea wearing different clothes. More on that at the end.
Line numbers are the wrong primitive
Here's the thing about a line number: it is only true at the instant you read it.
An agent's edit loop is a sequence of separate MCP calls with gaps between them, and in those gaps the file is not frozen. Another tool call edits it. A formatter runs on save. A parallel agent touches the same file. The human, watching the session, fixes a typo. By the time the edit arrives, "line 42" points at whatever happens to be sitting in the 42nd slot — and a filesystem server that takes a bare integer has no way to know the difference between the line the model meant and the line it's about to destroy.
The usual mitigation is a whole-file staleness gate: stamp the file when it's viewed, refuse the edit if the mtime or hash changed. We had a version of that. It's blunt in both directions. It rejects edits to line 900 because someone touched line 3, and it forces a full re-read to recover — expensive, and the re-read is itself immediately stale. Worse, it says nothing useful. "The file changed" leaves the model with one move: view the whole file again and hope it wins the race this time.
The primitive was wrong. A line reference should carry enough information to check itself.
N:hh — position plus proof
In 0.9.0, view renders every line as N:hh|content:
1:a3|fn main() {<br>2:f1| println!("Hello");<br>3:0e|}<br>N is the 1-indexed position. hh is two hex characters — an FNV-1a hash of the line's content, folded from 32 bits down to 8. Edit tools take these composite IDs back as targets, and verify_line_id checks the hash against the file at apply time. Match, and the edit proceeds. Mismatch, and nothing is written.
The hash covers content only , never position. That looked like a detail when we wrote it and turned out to be the whole design. Because a line keeps its hash when it moves, a failed verification can go looking for where the content went — scan the file for lines with the expected hash, and you know that the target didn't vanish, it slid down by three.
Which is exactly what the error says:
Stale line id "42:c7" — the file changed since you viewed it. Current content around line 42:<br>40:1b| let config = load_config()?;<br>41:9f| let client = Client::new(&config);<br>42:2e| tracing::info!("client ready");<br>43:0a|<br>44:5d| run(client).await<br>Content matching hash c7 is now at: 45:c7 (your target may have moved).<br>Retry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.<br>Three things are in that message, and each one is deliberate. The current content around the target, with fresh IDs — so the model can retarget immediately. Where content matching the expected hash lives now, nearest candidates first — so a moved-but-otherwise-untouched line is a one-step fix. And a concrete view range to run if it wants to confirm rather than guess.
The model recovers from the error alone. No re-read of a 2,000-line file, no second race, no burned context. The error is the recovery instruction.
And because edit results come back as diffs with freshly computed IDs, edits chain. Do three batch_edit calls in a row and the second one's targets come from the first one's response — the file never needs re-viewing between them.
There is one honest trade-off, and it's in a comment in the source rather than buried: eight bits means a changed line keeps its hash with probability 1/256. We took it. The IDs stay short enough to be cheap in context and readable in a transcript, the position check catches every kind of gross drift, and the alternative — longer hashes on every line of every file view — costs tokens on literally every read to defend against a 0.4% case that the diff-back-with-fresh-IDs loop tends to surface anyway.
We also deleted the mode switch. Previous versions had a --line-mode flag choosing between number-based and hash-based...