Skip to content

Commit f28b3cb

Browse files
jgravelleclaude
andcommitted
release: v1.108.169 — the retrieval verdict survives compaction
Two defects in contracts shipped days earlier, both found while checking a zero-result search I had wrongly treated as proof of absence. 1. Every compact encoder dropped _meta.verdict. schema_driven.encode filters _meta through a strict allowlist and "verdict" was in none of the 15 schema files, so under MUNCH encoding the whole retrieval-verdict contract was invisible: state, channels, coverage, scorer, did_you_mean. Tools without a custom encoder kept theirs, so the signal was present where it was least needed and absent on search_symbols / search_text / get_ranked_context. Worst consequence: server.py mints the citable absent:<sha> ref onto _meta.verdict.evidence_ref, and compact callers never received it, along with the absence_citable/absence_blocked_by refusal reason. The token-saving layer was eating the safety layer. meta_keys could not be reused — it flattens to a scalar and would have stringified the verdict into a Python repr. New meta_json_blobs parameter instead, mirroring the existing json_blobs mechanism; all 15 schemas declare _META_JSON = ("verdict",). 2. search_text was never wired for the v1.108.168 rebuilding rule. That release wired search_symbols and get_ranked_context only. The absence chokepoint is generic — it fires on any _meta.verdict dict — so search_text could reach absent and mint a ref while structurally unable to detect a rewrite underneath its own scan. Honest cost: +58 tokens on a 20-result encoded search, +89 on a zero-result response. Carried at full fidelity; trimming inside the encoder would make compact and JSON disagree, which is the class of bug being fixed. No schema/tool-count/INDEX_VERSION change. New tests/test_v1_108_169.py (10), including a guard that every schema declaring _META also declares verdict in _META_JSON so a future encoder cannot reintroduce the drop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3f116c3 commit f28b3cb

22 files changed

Lines changed: 329 additions & 49 deletions

CHANGELOG.md

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

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

5+
## [1.108.169] - 2026-07-25 - the retrieval verdict survives compaction
6+
7+
### Fixed
8+
9+
- **Every compact encoder silently dropped `_meta.verdict`.** `schema_driven.encode`
10+
filters `_meta` through a strict allowlist (`for k in meta_keys: if k in meta`),
11+
and `verdict` appeared in **none of the 15** schema files. Under MUNCH encoding
12+
the whole retrieval-verdict contract was invisible: `state`, `channels`,
13+
`coverage`, `scorer`, and the `did_you_mean` near-misses never reached the
14+
caller. Tools *without* a custom encoder (`get_file_content`) kept their
15+
verdict, so the signal was present exactly where it was least needed and
16+
absent on `search_symbols` / `search_text` / `get_ranked_context` — the three
17+
tools the absence contract is built on.
18+
19+
**The sharpest consequence was on absence evidence.** `server.py`'s chokepoint
20+
mints the citable `absent:<sha>` ref onto `_meta.verdict.evidence_ref`, and in
21+
compact mode the caller never received it — so a proof the server had already
22+
recorded could not be cited, and the in-band `absence_citable:false` /
23+
`absence_blocked_by` refusal reason was discarded with it. The token-saving
24+
layer was eating the safety layer.
25+
26+
Fix: new `meta_json_blobs` parameter on `schema_driven.encode`/`decode`,
27+
mirroring the existing `json_blobs` mechanism. A structured `_meta` value rides
28+
as `__json._meta.<key>` and round-trips as a real dict. **`meta_keys` could not
29+
be reused** — that path flattens to a scalar, which would have stringified the
30+
verdict into a Python repr. All 15 schemas now declare `_META_JSON = ("verdict",)`.
31+
32+
- **`search_text` was never wired for the v1.108.168 rebuilding rule.** That
33+
release added `index_changed=index_changed_since_load(index)` to
34+
`search_symbols` and `get_ranked_context` only. The absence chokepoint is
35+
**generic** — it fires on any `_meta.verdict` dict — so `search_text` could
36+
reach `absent` and mint a citable ref while structurally unable to detect a
37+
rewrite underneath its own scan. Same hole as the one .168 closed, left open in
38+
the third tool. Now passes `index_changed=`.
39+
40+
### Notes
41+
42+
- **Honest cost:** carrying the verdict adds ~58 tokens to a 20-result encoded
43+
search (+18.6%) and ~89 tokens to a zero-result response. The zero-result case
44+
is where the value is — that is precisely the response an agent would otherwise
45+
read as proven absence. The verdict is carried at **full fidelity**: trimming it
46+
inside the encoder would make compact and JSON disagree, which is the exact
47+
class of bug being fixed here. If the `ok` verdict is too chatty (`note`
48+
duplicates `state` in prose), that is a verdict-construction change for its own
49+
release, not something the compaction layer gets to decide.
50+
- No new tool, no schema/tool-count/INDEX_VERSION change. New
51+
`tests/test_v1_108_169.py` (10), including a guard that every schema declaring
52+
`_META` also declares `verdict` in `_META_JSON`, so a future encoder cannot
53+
reintroduce the drop.
54+
555
## [1.108.168] - 2026-07-24 - a rebuild underneath a scan cannot prove absence (5th refusal rule)
656

757
### Fixed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ is a byte the agent doesn't pay to read.
129129
<!-- WHATSNEW:START -->
130130
#### What's new
131131

132-
- **[v1.108.154](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.154)** (2026-07-21) — `surface` CLI subcommand
133-
- **[v1.108.153](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.153)** (2026-07-21) — tool-surface schema receipt in session stats
134-
- **[v1.108.152](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.152)** (2026-07-21) — runtime identity resource (#371)
132+
- **[v1.108.169](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.169)** (2026-07-25) — the retrieval verdict survives compaction
133+
- **[v1.108.168](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.168)** (2026-07-24) — a rebuild underneath a scan cannot prove absence (5th refusal rule)
134+
- **[v1.108.167](https://github.com/jgravelle/jcodemunch-mcp/releases/tag/v1.108.167)** (2026-07-24) — cue-anchored delivery ledger: measure what we hand over twice
135135
<!-- WHATSNEW:END -->
136136

137137
![License](https://img.shields.io/badge/license-dual--use-blue)
@@ -346,6 +346,8 @@ Every confidence constant the suite emits traces to a stated basis: **`measured`
346346

347347
An absence claim is also refused when the ground moved under it. A zero-result scan proves nothing if the index was **being rewritten while the scan read it** — the target may sit in rows written after the scan passed them — so that case reports `degraded` and `channels.index: "rebuilding"` instead of `absent`. This is caught by re-checking the index file itself rather than in-process reindex state, because the rebuild is usually driven by a *separate* watcher process that in-process state cannot see. The rebuild is disclosed on every verdict, not only the refused one: a caller reading a successful result still deserves to know the index moved under it, and only the absence *claim* is withheld.
348348

349+
The verdict survives compaction. jCodeMunch's compact wire format encodes `_meta` through a strict allowlist, and the verdict is carried through it deliberately rather than trimmed for bytes — a safety signal the token-saving layer deletes is no safety signal, and a dropped verdict turns "the scan was degraded" into a confident-looking empty result. That applies to the absence `evidence_ref` too: a proof the server has already recorded stays citable in every response format.
350+
349351
Absence claims carry their own receipts: an `absent` or `degraded` verdict discloses a **coverage contract** — what the corpus *excluded* at index time (unsupported extensions, oversize/binary/secret skips, cap-dropped files, zero-symbol files) plus the index generation it was scanned against — so "scanned N symbols, found nothing" can't lie by omission. An index that predates the contract omits the block: coverage unknown is never presented as "nothing was excluded". Every verdict is also pinned to a `scorer` version, and `benchmarks/calibration/planted_queries.json` records planted positive/negative query rates re-measured live in CI — a scorer change without a re-measured artifact fails the build.
350352

351353
The first gold corpus is in: `benchmarks/goldset/` is an authored implementation-pattern corpus (declared subclasses, duck-typed conformers, decorator-registered handlers — plus deliberate false-positive traps: module-homonym base classes, same-name-different-domain methods, substring decorator matches), fully labeled with per-pair rationale. `benchmarks/goldset/measure.py` re-runs `find_implementations` against it and CI asserts the committed results (`benchmarks/provenance/channel_accuracy.json`) match the live measurement — the numbers literally cannot drift from the reproducible run. Each resolution channel's registry entry now carries its `measured_ref` (precision/recall on the corpus) beside the declared ranking prior, and `_meta.confidence_provenance` surfaces both. Scope is stated in the artifact: authored-pattern discrimination, not in-the-wild base rates.

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.168"
3+
version = "1.108.169"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/jcodemunch_mcp/encoding/schema_driven.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def encode(
7878
nested_dicts: dict[str, list[str]] | None = None,
7979
meta_keys: Iterable[str] = (),
8080
json_blobs: Iterable[str] = (),
81+
meta_json_blobs: Iterable[str] = (),
8182
) -> tuple[str, str]:
8283
tables = list(tables)
8384
nested_dicts = nested_dicts or {}
@@ -110,6 +111,16 @@ def encode(
110111
for k in meta_keys:
111112
if k in meta:
112113
scalar_payload[f"_meta.{k}"] = meta[k]
114+
# Structured _meta values (dicts/lists) that must survive compaction intact.
115+
# meta_keys flattens to a scalar, which would stringify a dict — the retrieval
116+
# verdict is the reason this exists: dropping it hands the agent a confident
117+
# zero-result answer with no way to know the scan was degraded (and silently
118+
# discards the absence evidence_ref minted in call_tool).
119+
for k in meta_json_blobs:
120+
if k in meta:
121+
scalar_payload[f"__json._meta.{k}"] = json.dumps(
122+
meta[k], separators=(",", ":")
123+
)
113124
for k in json_blobs:
114125
if k in response:
115126
scalar_payload[f"__json.{k}"] = json.dumps(response[k], separators=(",", ":"))
@@ -173,6 +184,7 @@ def decode(
173184
nested_dicts: dict[str, list[str]] | None = None,
174185
meta_keys: Iterable[str] = (),
175186
json_blobs: Iterable[str] = (),
187+
meta_json_blobs: Iterable[str] = (),
176188
scalar_types: Mapping[str, str] | None = None,
177189
) -> dict:
178190
tables = list(tables)
@@ -224,6 +236,13 @@ def decode(
224236
prefixed = f"_meta.{k}"
225237
if prefixed in raw_scalars:
226238
meta_out[k] = _coerce(raw_scalars[prefixed], stypes.get(prefixed, "str"))
239+
for k in meta_json_blobs:
240+
prefixed = f"__json._meta.{k}"
241+
if prefixed in raw_scalars:
242+
try:
243+
meta_out[k] = json.loads(raw_scalars[prefixed])
244+
except Exception:
245+
meta_out[k] = raw_scalars[prefixed]
227246
if meta_out:
228247
result["_meta"] = meta_out
229248
# JSON blobs

src/jcodemunch_mcp/encoding/schemas/find_importers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
]
1717
_SCALARS = ("repo", "file_path", "importer_count", "note")
1818
_META = ("timing_ms", "truncated", "tokens_saved", "total_tokens_saved")
19+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
1920
_JSON = ("results",)
2021

2122

2223
def encode(tool: str, response: dict) -> tuple[str, str]:
23-
return sd.encode(tool, response, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON)
24+
return sd.encode(tool, response, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON)
2425

2526

2627
def decode(payload: str) -> dict:
27-
return sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON)
28+
return sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON)

src/jcodemunch_mcp/encoding/schemas/find_references.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
]
1919
_SCALARS = ("repo", "identifier", "reference_count", "note")
2020
_META = ("timing_ms", "truncated", "tokens_saved", "total_tokens_saved")
21+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
2122
_JSON = ("results", _EMPTY_GROUPS_KEY)
2223

2324

@@ -78,12 +79,12 @@ def _regroup(decoded: dict) -> dict:
7879

7980
def encode(tool: str, response: dict) -> tuple[str, str]:
8081
if "references" not in response:
81-
return sd.encode(tool, response, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON)
82-
return sd.encode(tool, _flatten(response), ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON)
82+
return sd.encode(tool, response, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON)
83+
return sd.encode(tool, _flatten(response), ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON)
8384

8485

8586
def decode(payload: str) -> dict:
86-
decoded = sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON)
87+
decoded = sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON)
8788
if _ROWS_KEY in decoded:
8889
return _regroup(decoded)
8990
return decoded

src/jcodemunch_mcp/encoding/schemas/get_blast_radius.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,19 @@
2626
)
2727
_NESTED = {"symbol": ["id", "name", "kind", "file", "line"]}
2828
_META = ("timing_ms",)
29+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
2930
_JSON = ("impact_by_depth", "callers", "cross_repo_confirmed")
3031

3132

3233
def encode(tool: str, response: dict) -> tuple[str, str]:
3334
return sd.encode(
3435
tool, response, ENCODING_ID, _TABLES, _SCALARS,
35-
nested_dicts=_NESTED, meta_keys=_META, json_blobs=_JSON,
36+
nested_dicts=_NESTED, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
3637
)
3738

3839

3940
def decode(payload: str) -> dict:
4041
return sd.decode(
4142
payload, _TABLES, _SCALARS,
42-
nested_dicts=_NESTED, meta_keys=_META, json_blobs=_JSON,
43+
nested_dicts=_NESTED, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
4344
)

src/jcodemunch_mcp/encoding/schemas/get_call_hierarchy.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,19 @@
3131
_SCALARS = ("repo", "direction", "depth", "depth_reached", "caller_count", "callee_count")
3232
_NESTED = {"symbol": ["id", "name", "kind", "file", "line"]}
3333
_META = ("timing_ms", "methodology", "confidence_level", "source", "tip")
34+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
3435
_JSON = ("resolution_tiers",)
3536

3637

3738
def encode(tool: str, response: dict) -> tuple[str, str]:
3839
return sd.encode(
3940
tool, response, ENCODING_ID, _TABLES, _SCALARS,
40-
nested_dicts=_NESTED, meta_keys=_META, json_blobs=_JSON,
41+
nested_dicts=_NESTED, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
4142
)
4243

4344

4445
def decode(payload: str) -> dict:
4546
return sd.decode(
4647
payload, _TABLES, _SCALARS,
47-
nested_dicts=_NESTED, meta_keys=_META, json_blobs=_JSON,
48+
nested_dicts=_NESTED, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
4849
)

src/jcodemunch_mcp/encoding/schemas/get_dependency_cycles.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
]
1818
_SCALARS = ("repo", "cycle_count")
1919
_META = ("timing_ms",)
20+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
2021

2122

2223
def encode(tool: str, response: dict) -> tuple[str, str]:
@@ -26,11 +27,11 @@ def encode(tool: str, response: dict) -> tuple[str, str]:
2627
{"length": len(c), "files": _CYCLE_SEP.join(c)}
2728
for c in r["cycles"] if isinstance(c, list)
2829
]
29-
return sd.encode(tool, r, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META)
30+
return sd.encode(tool, r, ENCODING_ID, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON)
3031

3132

3233
def decode(payload: str) -> dict:
33-
result = sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META)
34+
result = sd.decode(payload, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON)
3435
if "cycles" in result:
3536
result["cycles"] = [
3637
c["files"].split(_CYCLE_SEP) for c in result["cycles"]

src/jcodemunch_mcp/encoding/schemas/get_dependency_graph.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,18 @@
2626
"node_count", "edge_count",
2727
)
2828
_META = ("timing_ms", "truncated", "cross_repo", "tokens_saved", "total_tokens_saved")
29+
_META_JSON = ("verdict",) # structured _meta that must survive compaction
2930
_JSON = ("nodes", "neighbors")
3031

3132

3233
def encode(tool: str, response: dict) -> tuple[str, str]:
3334
return sd.encode(
3435
tool, response, ENCODING_ID, _TABLES, _SCALARS,
35-
meta_keys=_META, json_blobs=_JSON,
36+
meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
3637
)
3738

3839

3940
def decode(payload: str) -> dict:
4041
return sd.decode(
41-
payload, _TABLES, _SCALARS, meta_keys=_META, json_blobs=_JSON,
42+
payload, _TABLES, _SCALARS, meta_keys=_META, meta_json_blobs=_META_JSON, json_blobs=_JSON,
4243
)

0 commit comments

Comments
 (0)