Skip to content

Commit 1e2face

Browse files
jgravelleclaude
andcommitted
release: v1.108.178 — a cached negative must prove it still describes the subject
#377 hardening item 3 (@mightydanp). The search_symbols result cache keys on the index's indexed_at, so it invalidates on a reindex and nothing else. A query absent against snapshot A cached its fresh/absent verdict; the source changed without a rebuild; the same query hit the cache; the old verdict was replayed and minted an absence proof against a state nobody scanned. retrieval/subject_state.py captures index generation, .db mtime, live git HEAD and (for an absence only) the working-tree fingerprint at cache-write time, and re-checks at cache-read time. On a move the verdict discloses revalidated, an absent state downgrades to degraded, and the token minted on the earlier pass is stripped. Also: _result_cache_get did not copy the nested verdict, so the evidence_ref the dispatcher writes after the tool returns landed in the stored entry and was replayed to every later hit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bd87053 commit 1e2face

8 files changed

Lines changed: 573 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.178] - 2026-07-26 - a cached negative must prove it still describes the subject
6+
7+
### Fixed
8+
9+
- **A cached absence could be replayed over a tree that no longer existed.**
10+
Item 3 of the [#377](https://github.com/jgravelle/jcodemunch-mcp/issues/377)
11+
review by @mightydanp. The `search_symbols` result cache keys on the index's
12+
`indexed_at`, so it invalidates on a REINDEX and on nothing else. A query that
13+
was absent against snapshot A cached its `fresh` / `absent` verdict; the source
14+
then changed without the index being rebuilt; the same query hit the cache; and
15+
the old verdict was replayed, minting an absence proof against a state nobody
16+
had scanned.
17+
- New `retrieval/subject_state.py` captures what the answer depends on at
18+
cache-write time (index generation, .db mtime, live git HEAD, and for an
19+
absence the working-tree fingerprint) and re-checks it at cache-read time.
20+
When something moved, `verdict.revalidated` is disclosed, an `absent` state is
21+
downgraded to `degraded`, and any evidence token minted on the earlier pass is
22+
stripped, because it was issued against the state that just failed to hold.
23+
- **The dispatcher's write leaked into the cache.** `_result_cache_get` copied
24+
the result and its `_meta` but not the nested `verdict`, so the `evidence_ref`
25+
the dispatcher attached AFTER the tool returned landed in the stored entry and
26+
was replayed to every later hit. The copy now reaches the verdict, and the
27+
stored entry is written pristine.
28+
29+
### Notes
30+
31+
- **Only a negative pays for the expensive signal.** `git status` runs only when
32+
the cached verdict is `absent` — the one answer a working-tree edit can falsify
33+
while the index and its generation sit still. A cached positive costs a few
34+
stats, and its results were really in the index at that generation, so it keeps
35+
serving with disclosure. That split is the reviewer's, and it is right.
36+
- **Unknown is never reported as changed.** A non-git checkout knows none of
37+
this and did not change either; treating unknown as moved would refuse every
38+
absence on such a repo. Same reasoning as the tri-state `coverage.complete`.
39+
- The refusal names what moved (rebuilt, rewritten, checkout moved with both
40+
short SHAs, working tree changed) rather than falling through to the generic
41+
"the verdict was degraded".
42+
- Verified on a real checkout, not only on fixtures: the same absent query is
43+
replayed unchanged when nothing moves, and downgrades with
44+
`revalidated.stale_cache` the moment an untracked file appears.
45+
- Working-tree state here is scoped to the cached-negative path. Whole-scope
46+
working-tree semantics for a LIVE absence is item 5 and is not in this release.
47+
- `revalidated` published in `schemas/retrieval-verdict.schema.json`. New
48+
`tests/test_v1_108_178.py` (20). No tool-count or INDEX_VERSION change.
49+
550
## [1.108.177] - 2026-07-26 - an omitted match is not an absent match
651

752
### Fixed

CLAUDE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# jcodemunch-mcp — Project Brief
22

33
## Current State
4-
- **Version:** 1.108.177 — **An omitted match is not an absent match (#377 post-ship review by @mightydanp, hardening items 1/7/8/9/10/11/12).** ⚠ **The false-attestation path was real and LIVE: `search_symbols` SAVED `heap_count` before packing and then passed the POST-packing count into `build_verdict`, so matches that did not fit the token budget left `result_count == 0` → `absent` → citable `evidence_ref` over a corpus that CONTAINED the target.** Reproduced on the live path (token_budget=1 → 1 match found, 0 returned). Fix: `build_verdict(matches_before_packing=)` → `degraded` + `verdict.omitted` disclosed on EVERY state; `search_symbols` passes the heap, `get_ranked_context` passes the candidate set (the zero-candidate case returns earlier, so anything reaching the verdict really matched). ⚠ **`search_text` had THREE holes at once: two bare `continue`s (unsafe path, `OSError`) meant 3 unreadable of 10 read as a complete sweep of 7; a `file_pattern` matching NOTHING returned `absent`; and it NEVER passed `index_stale`, so the stale gate its siblings enforce could not fire there.** Now `verdict.incomplete.reason` ∈ {`unreadable_inputs`, `empty_scope`} + a real `FreshnessProbe`. ⚠ **Item 10 is the ORDERING one: `meta_fields` filtering ran BEFORE the absence block, so the shipped default `meta_fields: []` deleted the verdict and the evidence was never recorded, and the narrower `meta_fields: ["verdict"]` deleted `index_truncated` and handed `note_absence` a TRUNCATED scan as untruncated — minting exactly the ref the truncation gate exists to refuse. A DISPLAY preference must never decide whether a scan counts as complete.** The argument-contract and absence blocks now run on the COMPLETE internal result, and what filtering removes is re-attached as `_meta.absence_evidence` (jdoc/jdata's carrier shape); `absence_evidence` joins `UNIVERSAL_META_JSON` so compaction cannot drop it (item 11). ⚠ **Found while wiring that, NOT reported: `suppress_meta` is a DISPATCHER argument no tool schema declares, so v1.108.175 counted it as a caller mistake, downgraded the verdict, and cost well-formed calls their absence evidence** — now in `PROTOCOL_KEYS` with `_current_model`. Item 12: `is_regex`/`decorator`/`semantic*`/`fuzzy*`/`fusion`/`sort_by`/`strategy` join `_SCOPE_ARGS` (a literal and a regex search of the same string are DIFFERENT operations and must not collide on one id). Legacy `negative_evidence` suppressed on packed-empty/incomplete — `search_symbols` renders it as "Do not claim this feature exists", which is FALSE when the packer dropped the matches. `omitted` + `incomplete` published in `schemas/retrieval-verdict.schema.json`. New `tests/test_v1_108_177.py` (26); non-vacuity proven on the live path (the same scan reported the old way is `absent` and mints a ref). No tool-count/INDEX_VERSION change.
4+
- **Version:** 1.108.178 — **A cached negative must prove it still describes the subject (#377 hardening item 3).** ⚠ **The `search_symbols` result cache keys on `indexed_at`, so it invalidates on a REINDEX and NOTHING ELSE: a query absent against snapshot A cached its `fresh`/`absent` verdict, the source changed WITHOUT a rebuild, the same query hit the cache, and the old verdict was replayed — minting an absence proof against a state nobody scanned.** New `retrieval/subject_state.py` captures index generation + `.db` mtime + live git HEAD (+ the working-tree fingerprint for an absence) at cache-WRITE time and re-checks at cache-READ time; on a move it discloses `verdict.revalidated`, downgrades `absent` → `degraded`, and STRIPS any token minted on the earlier pass (it was issued against the state that just failed to hold). ⚠ **Only a NEGATIVE pays for `git status` — the one answer a working-tree edit can falsify while the index and its generation sit still. A cached positive costs a few stats and keeps serving with disclosure: its rows were really in the index at that generation.** ⚠ **UNKNOWN is never reported as CHANGED** (a non-git checkout knows none of this and did not change either; treating unknown as moved would refuse every absence there — same shape as tri-state `coverage.complete`). ⚠ **Second bug fixed in passing: `_result_cache_get` copied the result and `_meta` but NOT the nested `verdict`, so the `evidence_ref` the DISPATCHER attaches after the tool returns landed in the STORED entry and was replayed to every later hit.** ⚠ **Verified on a REAL checkout, not just fixtures: unchanged tree replays `absent` identically; an untracked file appearing flips the next hit to `degraded` + `revalidated.stale_cache`.** Working-tree state is scoped to the CACHED-negative path here — whole-scope semantics for a LIVE absence is item 5, NOT in this release. `revalidated` published in the verdict schema. New `tests/test_v1_108_178.py` (20). No tool-count/INDEX_VERSION change.
5+
- **Prior (1.108.177):** — **An omitted match is not an absent match (#377 post-ship review by @mightydanp, hardening items 1/7/8/9/10/11/12).** ⚠ **The false-attestation path was real and LIVE: `search_symbols` SAVED `heap_count` before packing and then passed the POST-packing count into `build_verdict`, so matches that did not fit the token budget left `result_count == 0` → `absent` → citable `evidence_ref` over a corpus that CONTAINED the target.** Reproduced on the live path (token_budget=1 → 1 match found, 0 returned). Fix: `build_verdict(matches_before_packing=)` → `degraded` + `verdict.omitted` disclosed on EVERY state; `search_symbols` passes the heap, `get_ranked_context` passes the candidate set (the zero-candidate case returns earlier, so anything reaching the verdict really matched). ⚠ **`search_text` had THREE holes at once: two bare `continue`s (unsafe path, `OSError`) meant 3 unreadable of 10 read as a complete sweep of 7; a `file_pattern` matching NOTHING returned `absent`; and it NEVER passed `index_stale`, so the stale gate its siblings enforce could not fire there.** Now `verdict.incomplete.reason` ∈ {`unreadable_inputs`, `empty_scope`} + a real `FreshnessProbe`. ⚠ **Item 10 is the ORDERING one: `meta_fields` filtering ran BEFORE the absence block, so the shipped default `meta_fields: []` deleted the verdict and the evidence was never recorded, and the narrower `meta_fields: ["verdict"]` deleted `index_truncated` and handed `note_absence` a TRUNCATED scan as untruncated — minting exactly the ref the truncation gate exists to refuse. A DISPLAY preference must never decide whether a scan counts as complete.** The argument-contract and absence blocks now run on the COMPLETE internal result, and what filtering removes is re-attached as `_meta.absence_evidence` (jdoc/jdata's carrier shape); `absence_evidence` joins `UNIVERSAL_META_JSON` so compaction cannot drop it (item 11). ⚠ **Found while wiring that, NOT reported: `suppress_meta` is a DISPATCHER argument no tool schema declares, so v1.108.175 counted it as a caller mistake, downgraded the verdict, and cost well-formed calls their absence evidence** — now in `PROTOCOL_KEYS` with `_current_model`. Item 12: `is_regex`/`decorator`/`semantic*`/`fuzzy*`/`fusion`/`sort_by`/`strategy` join `_SCOPE_ARGS` (a literal and a regex search of the same string are DIFFERENT operations and must not collide on one id). Legacy `negative_evidence` suppressed on packed-empty/incomplete — `search_symbols` renders it as "Do not claim this feature exists", which is FALSE when the packer dropped the matches. `omitted` + `incomplete` published in `schemas/retrieval-verdict.schema.json`. New `tests/test_v1_108_177.py` (26); non-vacuity proven on the live path (the same scan reported the old way is `absent` and mints a ref). No tool-count/INDEX_VERSION change.
56
- **Prior (1.108.176):** — **A partial index stops reporting itself fresh (#375 sub-problem C — the one the customer gated on).** Reported symptom: **"7,659 of 9,634 code files learned, but it still reports itself up to date, so 'I never learned that file' and 'that file doesn't exist' look identical to every agent"** — the ambiguity that moved a paying user's default code lookup to another tool. ⚠ **The check was NOT broken; it answered a DIFFERENT QUESTION. Freshness compares index SHA vs git HEAD = "is the index BEHIND the tree in TIME", and was being read as "does the index COVER the tree". A corpus that dropped files sits at the SAME SHA, so `fresh` was literally true and completely misleading.** Root cause underneath: `index_folder`'s SECOND PASS (`file_mtimes` build, ~line 1980) dropped files with **THREE UNCOUNTED `continue`s** (validate_path fail, relative_to fail, no-language) — the corpus could end up smaller than the walk reported with **nothing anywhere recording the difference**. Fix: count drops by reason (`outside_root`/`no_language`/`stat_failed`), persist `files_accepted` beside `files_indexed` + `unaccounted` for the unexplained remainder, and add **`channels.index: "partial"`**. ⚠ **`complete` is TRI-STATE — true/false/None-for-unknown; a pre-accounting index reports None and None MUST NEVER read as true, so every existing index is byte-identical until re-indexed.** ⚠ **DISCLOSE every state, REFUSE only the absence CLAIM** (a file that never entered the corpus can't be proven absent from it); refusal-as-downgrade means `absence_refusal` does the work, no second rule. Precedence `rebuilding` > `partial` > `stale` > `fresh`. ⚠ **A partial walk over a WIDER index (v1.96 subdir-merge / branch-delta) reports UNKNOWN, not incomplete** — else those modes fire a permanent false alarm. ⚠ **The published `retrieval-verdict` schema closes BOTH `channels` and the `coverage` block, so both needed extending or every mid-gap response fails OUR OWN contract (v1.108.168 trap, fired AGAIN here) — caught only by validating the PROJECTED `index_coverage_meta` shape, not the raw persisted report.** Measured on this repo before shipping to confirm the signal isn't noise: **672 accepted / 672 indexed / `complete: true`, zero false positives.** New `tests/test_v1_108_176.py` (20); non-vacuity proven (gate off → 2 provably-missing files still yield `absent` + `fresh` + `absent:cda93cdd5447`). No tool-count/INDEX_VERSION change.
6-
- **Prior (1.108.175):** — **An ignored argument cannot prove absence.** Found LIVE while auditing #375, by me, against our own repo: a `search_text` call passed `regex=true` — **the parameter is `is_regex`**. Every tool reads its arguments key-by-key (`arguments.get("is_regex", False)`), so the flag was **dropped in silence**, the regex SOURCE TEXT was searched as a LITERAL substring, nothing matched, and the response reached `state:"absent"` with "treat this as strong evidence the target is not present" **plus a citable `evidence_ref`** — over a corpus that plainly contained the target (`_record_coverage` is in `index_folder.py` three times). ⚠ **The dangerous half is not the wrong answer, it is that a MISUNDERSTOOD CALL minted an absence PROOF a handoff could cite.** Fix: dispatcher diffs each call's arguments against the tool's **published `inputSchema`** (`_declared_arg_keys`, built once from the same catalog `list_tools` publishes, so the contract cannot drift from what the agent was shown) → `_meta.ignored_arguments` + `absent`→`degraded` with a note naming the keys. ⚠ **DISCLOSE on every state, REFUSE only the absence CLAIM — same shape as the 1.108.168 `rebuilding` gate; expressing the refusal as a DOWNGRADE means `handoff.absence_refusal` does the refusing and there is no second rule to keep in sync.** ⚠ **Deliberately NEVER rejects the call:** under the 1.x zero-surprise contract an unknown key has always been accepted, so a client sending a harmless extra for a year must not start erroring, and a hard reject turns a recoverable mistake into a dead call. ⚠ **An unknown schema accuses NOBODY** (unreadable declaration → return nothing, never guess) else every tool we failed to read manufactures warnings. ⚠ **New `UNIVERSAL_META_JSON` in `encoding/schema_driven.py` — structured `_meta` keys EVERY encoder preserves regardless of its own allowlist. A per-schema allowlist is right for tool payloads and WRONG for CONTRACT keys: the encoder drops the key, the response still looks complete, and the field that said "don't trust this" is the one that vanishes. That is exactly how the verdict contract went invisible in 1.108.169 — and adding a key to 45 tuples only guarantees the 46th encoder forgets it.** New `tests/test_v1_108_175.py` (21), non-vacuity proven (gate disabled → the same call still mints `absent:2a1a6b8520c3`). No schema/tool-count/INDEX_VERSION change.
7-
- **Older releases (1.108.174 and earlier):** see `CHANGELOG.md` — the authoritative version history (412 entries, 1.0.0 onward).
7+
- **Older releases (1.108.175 and earlier):** see `CHANGELOG.md` — the authoritative version history (413 entries, 1.0.0 onward).
88
- **INDEX_VERSION:** 17
9-
- **Tests:** 5832 passed, 7 skipped (1.108.177) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
9+
- **Tests:** 5852 passed, 7 skipped (1.108.178) + the KNOWN 12 local-ONNX `test_semantic_search` env failures (green in CI on all 4 ubuntu jobs — never read them as a regression)
1010
- **Python:** >=3.10
1111
- **Tool count:** 90 in `full` (front door hidden; +1 v1.108.111 `get_parity_map`, +1 v1.108.112 `get_decorator_census`, +1 v1.108.113 `get_architecture_metrics`); `tool_surface=counter` exposes a 3-tool front door (`order`/`menu`/`route`) instead
1212

@@ -50,6 +50,7 @@ src/jcodemunch_mcp/
5050
enrichment/
5151
lsp_bridge.py # LSP bridge — opt-in compiler-grade call graph resolution via pyright/gopls/ts-language-server/rust-analyzer; LSPServer lifecycle, LSPBridge multi-server manager, enrich_call_graph_with_lsp() + enrich_dispatch_edges() (interface/trait dispatch resolution)
5252
retrieval/
53+
subject_state.py # (v1.108.178) #377 item 3: what a scan's answer depends on, cheap enough to re-check. capture() at cache-WRITE (index generation, .db mtime, live git HEAD, + working-tree fingerprint ONLY for an absence) / changed() at cache-READ / revalidate_verdict() downgrades a replayed `absent` and strips the stale evidence token. UNKNOWN is never a change
5354
signal_fusion.py # Weighted Reciprocal Rank (WRR) fusion: lexical + structural + similarity + identity channels
5455
regret.py # (v1.108.68) analyze_regret: mines the ranking_events ledger for SIX retrieval-regret signals (requery_churn/low_confidence/thin_result/ambiguous_top/stale_at_query/vocabulary_gap) as severity-ranked clusters. Pure read via token_tracker.ranking_db_query; no new tables. Consumed by suggest_corrections + the digest one-liner
5556
summarizer/

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.177"
3+
version = "1.108.178"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

schemas/retrieval-verdict.schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@
101101
},
102102
"additionalProperties": false
103103
},
104+
"revalidated": {
105+
"type": "object",
106+
"description": "This response was replayed from the result cache and the subject it was measured against has since moved. Disclosed on every state; an 'absent' verdict is downgraded to 'degraded' and any evidence token minted on the earlier pass is stripped, because a cached negative cannot prove absence against a state it never saw.",
107+
"properties": {
108+
"stale_cache": {"type": "boolean"},
109+
"reason": {"type": "string", "description": "What moved: index rebuilt, index rewritten, checkout moved to a different commit, or working tree changed."}
110+
},
111+
"additionalProperties": false
112+
},
104113
"incomplete": {
105114
"type": "object",
106115
"description": "The scan did not cover its own eligible inputs. Disclosed on every state; when the result is empty the state is 'degraded', because a scan that did not run over its inputs cannot prove absence.",

0 commit comments

Comments
 (0)