{"slug": "octofs-mcp-0-9-0-line-42-is-a-lie", "title": "Octofs MCP 0.9.0: Line 42 Is a Lie", "summary": "Octofs 0.9.0 introduces content-verified line addressing, where every line is rendered as N:hh (position plus an 8-bit FNV-1a hash of its content), and edit tools verify the hash before writing. This prevents edits from landing on the wrong lines when files change between view and edit, a failure mode that previously caused silent corruption. The release also includes two other changes that embody the same principle of self-verifying references.", "body_md": "# Octofs 0.9.0: Line 42 Is a Lie\n\nThe agent asked to replace lines 40 through 44. It got lines 40 through 44. They were not the lines it had read.\n\nNothing crashed. No error surfaced. Somewhere between the `view`\n\nthat produced the plan and the `batch_edit`\n\nthat 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.\n\nThat's the failure mode this release exists to kill.\n\n**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.\n\nTwo other changes shipped alongside it, and they turn out to be the same idea wearing different clothes. More on that at the end.\n\n## Line numbers are the wrong primitive\n\nHere's the thing about a line number: it is only true at the instant you read it.\n\nAn 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.\n\nThe 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.\n\nThe primitive was wrong. A line reference should carry enough information to check itself.\n\n`N:hh`\n\n— position plus proof\n\nIn 0.9.0, `view`\n\nrenders every line as `N:hh|content`\n\n:\n\n```\n1:a3|fn main() {\n2:f1|    println!(\"Hello\");\n3:0e|}\n```\n\n`N`\n\nis the 1-indexed position. `hh`\n\nis 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`\n\nchecks the hash against the file at apply time. Match, and the edit proceeds. Mismatch, and nothing is written.\n\nThe 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.\n\nWhich is exactly what the error says:\n\n```\nStale line id \"42:c7\" — the file changed since you viewed it. Current content around line 42:\n40:1b|    let config = load_config()?;\n41:9f|    let client = Client::new(&config);\n42:2e|    tracing::info!(\"client ready\");\n43:0a|\n44:5d|    run(client).await\nContent matching hash c7 is now at: 45:c7 (your target may have moved).\nRetry with the fresh ids above, or run `view` with start: 40, end: 44 (or a wider range) to confirm before editing.\n```\n\nThree 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`\n\nrange to run if it wants to confirm rather than guess.\n\nThe 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.**\n\nAnd because edit results come back as diffs with freshly computed IDs, edits chain. Do three `batch_edit`\n\ncalls in a row and the second one's targets come from the first one's response — the file never needs re-viewing between them.\n\nThere 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.\n\nWe also deleted the mode switch. Previous versions had a `--line-mode`\n\nflag choosing between number-based and hash-based addressing. **That flag is gone, and the N:hh format is mandatory** — this is the breaking change in 0.9.0. Two addressing modes meant every tool description had to explain both, every model had to figure out which one it was talking to, and the safe mode was opt-in. Safety that ships behind a flag is safety most people never turn on.\n\nPlain integers still work where a position alone is genuinely safe and unverifiable-by-nature: `view`\n\nranges (negatives count from the end), and the insert anchors `0`\n\nfor file start and `-1`\n\nfor append. Everything that targets *existing* content requires an ID.\n\n## The edit that succeeded and did nothing\n\nWhile we were in there, we found a quieter version of the same bug.\n\n`str_replace`\n\nmatches progressively: exact match first, then a whitespace-normalized fuzzy pass for when the model's indentation drifted. On a file with CRLF line endings, the fuzzy pass would match on normalized text, then try to splice the replacement back into the raw content — where every line still ended in `\\r\\n`\n\n, so there was nothing to splice. It wrote the file back byte-identical and reported success with a diff. A Windows developer's edit went through the entire motion and changed nothing.\n\nNow all matching happens in LF space and `restore_endings`\n\nputs `\\r\\n`\n\nback on write. Same for `batch_edit`\n\n. The file keeps its endings; the matcher stops caring about them.\n\nThe full match ladder in 0.9.0 runs: **exact** → **escaped-literal recovery** → **whitespace-normalized fuzzy with indentation adjustment** → **diagnostics**. That second stage is new and is pure model-failure-mode engineering: when a model double-escapes its JSON and sends a literal backslash-n instead of a newline, we interpret the escapes, and if *that* matches uniquely we apply it and attach a hint saying what we did. It's a mistake models make constantly, it's unambiguous when it happens, and bouncing an error back for it was costing a round trip to fix nothing real.\n\n`replace_all`\n\nis new too — the rename-style edit that used to require either enough surrounding context to make each occurrence unique, or a `batch_edit`\n\nwith one operation per site. And when an exact match hits multiple times without `replace_all`\n\n, the error now lists every match location *as a line ID*:\n\n```\nFound 3 matches for replacement text at:\n  1. 12:a3\n  2. 88:a3\n  3. 140:a3\nAdd more surrounding context to make a unique match, pass `replace_all: true` to replace all 3 occurrences, or use `batch_edit` with the specific line ids.\n```\n\nThree named exits, and every one of them is executable without another `view`\n\n. That's the pattern, again.\n\n## Hints are advice, and models take advice selectively\n\nThe third change is the one that will annoy someone, so let me make the case for it.\n\nOctofs detects shell misuse — the model reaching for `cat`\n\n, `grep`\n\n, `find`\n\n, `ls`\n\n, `sed`\n\n, or `awk`\n\nwhen a dedicated MCP tool does the job better. Until 0.9.0 that detection was configurable via `--hint-mode`\n\n: warn softly, or reject. Soft was the default as of 0.8.1.\n\nSoft hints don't work. A warning appended to a successful response is a suggestion competing against a result the model already has in hand, and the result wins. We watched sessions where the same hint fired six times and the model kept running `grep`\n\n, because `grep`\n\nreturned output and the hint cost nothing to ignore.\n\n**In 0.9.0 shell misuse is always a hard error, and the mode switch is gone.** The call fails, nothing executes, and the error names the tool to use with a worked example:\n\n```\nSearching file text with this command is forbidden — use `view` with content= instead\n(gitignore-aware, context lines, line numbers, works on remote hosts).\n\n  Example:\n    view path=\"src/main.rs\" content=\"fulfill_input_requests\"\n    view path=\"src/\" content=\"TODO\" regex=true\n    view path=\"ssh://user@host/dir\" content=\"TODO\"  # remote search — no `ssh grep` needed\n```\n\nThis isn't tidiness. `view`\n\nwith `content=`\n\nreturns line IDs the edit tools accept, respects `.gitignore`\n\n, and transparently works against `ssh://`\n\npaths. Raw `grep`\n\noutput gives the model a line number — which, per everything above, is a lie waiting to happen — and silently drowns it in `node_modules`\n\n. The dedicated tool is strictly more useful, so the only question was whether to make choosing it optional. It isn't anymore.\n\nWhat still works: **pipelines**. `cargo build 2>&1 | grep error`\n\nis a stream transform, not a file read, and the detector deliberately doesn't split on `|`\n\n. It splits on `;`\n\n, `&&`\n\n, `||`\n\n, newlines, `$(`\n\n, and backticks — and only outside quotes, so `ssh host 'cd /path && ls'`\n\ndoesn't false-positive on a remote command it has no business inspecting. Env-var prefixes are skipped to find the real program, and `/bin/grep`\n\nis resolved down to `grep`\n\nso a path prefix isn't an escape hatch.\n\n## What these three have in common\n\nLook at them together and it's one change made three times.\n\nAn MCP server's real interface isn't its tool schema — it's every string it hands back to a model, and most of those strings are errors. For a human, an error is a notification; you read it, you go fix things by hand. For an agent, an error is a **prompt**. It's the entire input to the next decision, arriving with no other context, and the quality of what happens next is bounded by what that string contains.\n\nSo: don't say \"the file changed,\" say what it changed to and where the content went. Don't say \"3 matches found,\" list them as targets the next call can consume. Don't hint that `grep`\n\nis discouraged, fail and hand over the exact `view`\n\ncall that replaces it. Every one of those is the same move — spend a few hundred characters in the error to save a whole round trip and the context window it burns.\n\nThis is what we mean when we say we're aligning the server to the model rather than to the filesystem. Not prompt engineering. Interface design for a caller that recovers by reading, and only reads what you give it.\n\n## Upgrade\n\n```\n# Homebrew\nbrew upgrade muvon/tap/octofs\n\n# Cargo\ncargo install octofs --version 0.9.0\n```\n\nPre-built binaries for Linux, macOS, and Windows (x86_64 and ARM64) are on the [releases page](https://github.com/muvon/octofs/releases), and the release pipeline now publishes to npm alongside crates.io and the MCP registry.\n\n**One breaking change to know about:** if you set `--line-mode`\n\nor `--hint-mode`\n\nin your MCP client config, remove them — both flags are gone and the binary will reject them. There is nothing to replace them with; the safe behavior is now the only behavior. No other config changes.\n\nAfter upgrading, the difference shows up on the first edit that races something else. Instead of a silent success on the wrong lines, you get an error your agent can act on without reading the file again.\n\nOctofs is open source (Apache 2.0) at [github.com/Muvon/octofs](https://github.com/Muvon/octofs). If you want the security angle on why an agent should get a scoped filesystem tool instead of a raw shell at all, that's [a separate post](/blog/give-an-ai-agent-a-filesystem-safely) — this one was about making sure the tool edits the line it was actually pointed at.", "url": "https://wpnews.pro/news/octofs-mcp-0-9-0-line-42-is-a-lie", "canonical_source": "https://muvon.io/blog/octofs-0-9-0-content-verified-line-ids", "published_at": "2026-08-12 16:21:51+00:00", "updated_at": "2026-08-12 16:42:18.550450+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["Octofs"], "alternates": {"html": "https://wpnews.pro/news/octofs-mcp-0-9-0-line-42-is-a-lie", "markdown": "https://wpnews.pro/news/octofs-mcp-0-9-0-line-42-is-a-lie.md", "text": "https://wpnews.pro/news/octofs-mcp-0-9-0-line-42-is-a-lie.txt", "jsonld": "https://wpnews.pro/news/octofs-mcp-0-9-0-line-42-is-a-lie.jsonld"}}