Skip to content

fix(mcp): verify content for freshness and snippets; expose store generation (#2585) - #2600

Open
bline wants to merge 1 commit into
DeusData:mainfrom
bline:fix/snapshot-coherence
Open

bline wants to merge 1 commit into
DeusData:mainfrom
bline:fix/snapshot-coherence

Conversation

@bline

@bline bline commented Oct 12, 2026

Copy link
Copy Markdown

What does this PR do?

Addresses part of #2585: check_index_coverage and get_code_snippet now
judge a file by its content. When content verification succeeds, snippets
are sliced from the exact buffer checked; detected content drift withholds
source. Unverifiable files retain the existing fallback, marked
content_verified: false. Both responses also carry a publication
identifier. What this PR
does not cover is listed under Not in this PR.

Freshness by content. check_index_coverage compared only mtime and
size, although file_hashes.sha256 already holds the SHA-256 of the indexed
bytes. It now compares that hash:

freshness metadata bytes current?
metadata_match same same yes (now content-verified)
content_changed (new) same differ no: the same-size, preserved-mtime edit from case 1c
content_match (new) differ same yes: identical bytes rewritten, case 1d
metadata_changed differ differ no

A per-path content_verified flag says whether the hash check ran. A path
counts as current only when it did. If the recorded hash is unusable or the
file can't be read, the freshness label keeps its metadata value, for
compatibility. But a metadata match alone no longer earns
use_graph_with_best_effort_caveat or exact-path verification. The
recommended action is read_source_and_reindex. The indexer always records
a hash, so this only affects damaged rows and unreadable files.

Case 1d is fixed on the read side. Re-indexing identical bytes takes the
no-op route, which deliberately does not reopen the sealed generation for
writing, so the stored mtime stays as it was. Freshness now reports such
files as content_match instead of asking for a re-read that cannot help.

Snippets. get_code_snippet reads the file once, hashes those bytes, and
slices the indexed line range from the same buffer. A write that lands
between the check and the slice can't produce a content_verified snippet
with code that was never indexed. A test seam writes the file at exactly that
point to prove it. When the bytes are not the indexed bytes, the source is
withheld, and the response says source_drift: true, gives the freshness
value, and sets recommended_action: "reindex_then_retry". This is the #1750
symptom. #1788 and #2216 gate snippets on metadata, which case 1c gets past.
The content check covers that case too. Happy to reconcile field names with
either PR.

Publication identifier. check_index_coverage metadata and
get_code_snippet now carry store_generation (u<db_uid>g<mutation_gen>,
the identity query_graph cursors already use), which advances on every
index run. It is read when the response is built. It names the publication
current at that moment, but it doesn't make the response one atomic read
of that publication. metadata.generation keeps its meaning (the
coverage-recording run that generation_matches checks), so it is not
repurposed.

Not in this PR

Evidence

Reproduce-first tests in tests/test_mcp.c reach each state through
recorded hash rows rather than file times, so they behave the same on
Windows. Without the source change they fail on the defects themselves:

tool_check_index_coverage_freshness_verifies_content_issue2585
  FAIL tests/test_mcp.c:6574: "metadata_match" != "content_changed"
tool_get_code_snippet_withholds_source_whose_content_drifted_issue2585
  FAIL ...: strstr(source, "func") is not NULL
tool_responses_carry_store_generation_issue2585
  FAIL ...: "(null)" != "u4e49288846c1cf4eg1"

The two guarantees added in review were checked the same way, by reverting
each one in the source:

currency ignoring content_verified (unverified metadata match):
  FAIL tests/test_mcp.c:6616: "use_graph_with_best_effort_caveat" != "read_source_and_reindex"
snippet re-reading the file instead of slicing the verified bytes:
  tool_get_code_snippet_serves_the_bytes_it_verified_issue2585
  FAIL tests/test_mcp.c:6723: strstr(source, "Intruder") is not NULL

tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_issue1613
recorded an empty hash with matching metadata and expected exact-path
verification. Its fixture now records the file's real hash, as the indexer
does. Its own assertions are unchanged.

With the change, scripts/test.sh --suites mcp reports 371 passed, 4 skipped
(Linux x86-64, gcc, ASan+UBSan).

End to end, on a release build from this branch, using the scripts attached
to #2585 (all cases on throwaway repositories):

FIXED: store_generation changed after re-index (yes)
FIXED: snippet carries the same store_generation (ud1ac3951597d8cf5g2)
FIXED: source_drift reported (True)
FIXED: no misattributed source (False)
FIXED: freshness (content_changed)        # 1c
FIXED: freshness (content_match)          # 1d
FIXED: recommended_action does not demand a re-read (True)

Cost. check_index_coverage now hashes every path it checks (at most
128 per call). Measured on release builds through the CLI, with 128 tracked
files per call, best of 7 runs, all 128 paths verified:

file size total hashed main this PR
1 KiB 128 KiB 979 ms 981 ms
64 KiB 8 MiB 980 ms 978 ms
512 KiB 64 MiB 976 ms 974 ms
2 MiB 256 MiB 983 ms 1232 ms

Both builds have a fixed per-call CLI floor of about 980 ms. The hashing
cost appears only at the largest size: about 250 ms for 256 MiB, or about
1 GiB/s. get_code_snippet now reads the whole file it serves from, where
before it stopped at the symbol's last line.

Fork CI (dry-run.yml on bline/codebase-memory-mcp, run 38180923472):
lint, memory lint, security gates, and the test matrix pass on Windows
(x86-64 and ARM64), macOS 14 and 15, Ubuntu 22.04 x86-64 and 24.04 (x86-64
and ARM64), plus TSan, MSan, and LSan. The ubuntu-22.04-arm gcc shards fail
to compile src/foundation/sha256.c, which this PR does not touch
(inlining failed in call to 'always_inline' 'vsha256hq_u32': target specific option mismatch, gcc 11). That leg runs only in the dry-run matrix, not in
PR CI, so it looks like an existing break from #2441.

Checklist

  • Every commit is signed off (git commit -s) — required, CI rejects
    unsigned commits (DCO, see CONTRIBUTING.md)
  • Tests pass locally: scripts/test.sh (the canonical leg), 9142 passed, 0 failed, 10 skipped,
    177 suites, Linux x86-64, gcc
  • Lint passes: scripts/lint.sh --ci in test-infrastructure lint container
    (cppcheck + clang-format-20 + memory-core), "All linters passed"
  • New behavior is covered by a test (reproduce-first for bug fixes)

Written with an AI agent (Claude); I reviewed the change and certify it via
the DCO sign-off.

🤖 Generated with Claude Code

…eration (DeusData#2585)

check_index_coverage judged freshness by mtime and size alone, although
file_hashes records the SHA-256 of the indexed bytes. A same-size edit
with the original mtime passed as metadata_match. Identical bytes
rewritten with a new mtime stayed metadata_changed after re-indexing,
because the no-op incremental route never rewrites the record.
get_code_snippet sliced current disk bytes with indexed line numbers, so
it could return another symbol's code under the requested name. No
response carried an identity that changes on incremental re-index.

- Freshness now compares the file's SHA-256 with the recorded hash.
  metadata_match additionally means the bytes match. content_changed
  (new) means same metadata but different bytes; content_match (new)
  means stale metadata but identical bytes. metadata_changed keeps its
  meaning. A per-path content_verified flag says whether the hash check
  ran. A path counts as current only when it did: without a usable hash
  or a readable file, the freshness label keeps its metadata value, but
  the path recommends read_source_and_reindex and is not exact-path
  verified.
- get_code_snippet reads the file once, checks that buffer's hash, and
  slices the indexed range from the same buffer, so a concurrent write
  cannot produce a content_verified snippet of bytes that were never
  indexed. When the bytes are not the indexed bytes, it withholds the
  source and reports source_drift, freshness, and recommended_action
  "reindex_then_retry". Unlike a metadata gate, this also catches the
  same-size, preserved-mtime case. Unverifiable files keep the existing
  fallback, marked content_verified: false.
- check_index_coverage metadata and get_code_snippet now carry
  store_generation ("u<db_uid>g<mutation_gen>", the identity query_graph
  cursors already use), which advances on every index run. It names the
  publication current when the response is built; it does not make the
  response one atomic read. metadata.generation is unchanged: it names
  the coverage-recording run.

Tests (tests/test_mcp.c) reach each state through recorded hash rows
instead of file times, so they behave the same on every platform. Without
the fix they fail on the defects themselves:
  "metadata_match" != "content_changed"
  snippet source contains another function's code
  store_generation absent
  unverified metadata match recommended as current
  a write between check and slice is served as verified (test seam)
The DeusData#1613 truncated-catalog test now records the file's real hash, as
the indexer does, instead of an empty one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Scott Beck <296871+bline@users.noreply.github.com>
@bline
bline requested a review from DeusData as a code owner October 12, 2026 00:24
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant