NxtSoftLabs
← All writing

Stale index? How a code graph proves its answers are fresh

August 14, 2026·5 min read

Every answer CGraph's daemon returns carries a content root — a sha256-merkle-v1 hash naming the exact source tree the graph was built from — and any read can pin expected_content_root, in which case a daemon holding a different snapshot returns an error instead of a plausible-but-stale answer. This post covers how the root is built, the filesystem edit it catches that timestamp caches miss, and the pinning workflow an agent uses.

The lie a stale index tells

An index that's out of date doesn't fail — it answers. The blast radius looks smaller than it is, the caller list is missing the caller you just added, and nothing in the response looks wrong. For a human that's an annoyance; for a coding agent that edits based on the answer, it's how confident mistakes happen.

The usual defense is a cache keyed on file size and modification time. That covers most edits and misses the worst ones: build tools and git checkout can rewrite a file to the same length while a preserved timestamp makes it look untouched. A cache that trusts stat metadata will happily serve the old extraction — which is why CGraph's answer to "is this fresh?" is content hashes all the way down, rolled up into one value you can check.

One hash that names your source tree

On every build and verified rescan, the pipeline hashes each detected code file and folds the results into a merkle tree:

  • One leaf per code file. Each leaf binds the normalized project-relative path to that file's SHA-256 content hash — a leaf preimage is a 0x00 domain byte, the path length, the path, and the 32-byte file hash.
  • Order-independent by construction. Leaves are sorted by path before the tree is built, so the same files with the same bytes produce the same root no matter how the filesystem enumerated them.
  • Every change is a new name. Edit one byte, add a file, delete a file, or rename one, and the root changes. An empty tree gets its own domain-separated root rather than a magic constant.
content rootsha256-merkle-v1hash(A · B)hash(C · D)src/price.tshash changed — 1 bytesrc/types.tsunchangedlib/blog.tsunchangedlib/toSlug.tsunchangedchanged by the editunchanged branch
One file's content hash changes, so every hash on the path to the root changes with it. The root is a name for the whole tree's exact bytes.

The root travels with the graph. This site's repository currently indexes to 68 leaves, and every response from its daemon carries the same stamp:

"freshness": {
  "algorithm": "sha256-merkle-v1",
  "content_root": "7982ddd109477f1d6a7c6ae3130e7bf4…",
  "leaf_count": 68,
  "verified": true
}

Catching the edit that timestamps miss

Here is the scenario stat-based caches get wrong, run for real. A one-byte semantic change — cents / 100 becomes cents * 100 — written so the file keeps its exact size, with the modification time restored afterwards:

$ stat -f "size:%z mtime:%m" src/price.ts   # after the edit + touch -r
size:71 mtime:1786744713                    # identical to before

To stat, nothing happened. The content root disagrees:

before  eab093b2d262da4dc674cb0d604bad3e…   1 leaf
after   6ca67e6ddf799ded88130091d781209d…   1 leaf

This isn't luck; it's the contract. The spec requires that a content-verified rescan hash every detected file and never accept a metadata-only cache hit — an equal-length, preserved-mtime edit must produce a different root. Timestamps are only ever used as a hint for which files to look at, never as proof that nothing changed.

Pinning a read to a snapshot

The root would be trivia if you couldn't act on it. The workflow:

update
returns the new root
read, pinned
expected_content_root
served — or refused
on any other snapshot
Pin reads to the root the update returned. A daemon holding any other snapshot errors instead of answering.

An update synchronizes the graph and reports the root of the snapshot it published. Every read op — query, path, explain, impact, context — then accepts that root as a precondition:

$ cgraph-client impact '{"id":"toSlug","expected_content_root":"7982ddd1…"}'
{ "ok": true, "result": { …, "freshness": { "content_root": "7982ddd1…", "verified": true } } }

$ cgraph-client impact '{"id":"toSlug","expected_content_root":"00000000…"}'
{ "ok": false, "error": "expected_content_root does not match the selected graph snapshot" }

The failure mode flips from silently wrong to loudly refused — the difference between an agent editing on stale data and an agent knowing it must re-sync first. The MCP tools expose the same precondition, so an agent's discipline is two lines: call graph_update, thread the returned root through every subsequent read.

One honest subtlety: an unpinned read still reports the root of the snapshot it used, but that's a statement of identity — which tree this graph was built from — not a claim that no filesystem edit happened in the milliseconds since the last sync. The spec is explicit that watcher-driven freshness is eventual and is never represented as a full-tree verification. If you need the guarantee, pin.

The root also gates the fast path

The same value protects the daemon against itself. A restarting daemon may fast-load its persisted graph instead of re-extracting the world — but only after re-hashing the complete detected code tree and matching the file set, each file's hash, the root, the leaf count, and the index logic version against the persisted manifest. The preserved-mtime edit above doesn't just change query results; it rejects the fast-load outright and forces a verified rebuild. A cheap startup is never bought with an unverified graph.

Try it

Ask your own daemon for its current identity — it's in every response:

cgraph-client update '{"path":"."}'
cgraph-client impact '{"id":"someFunction","expected_content_root":"<root from update>"}'

Then make an edit and watch the pinned read refuse until you sync. Freshness is one half of trusting an answer; the other half is how fast a warm graph keeps up with your edits, which is covered in fold-in vs full rescan — and if you're new to the impact queries being pinned here, start with impact analysis in ~10ms.