Skip to content

Releases: jgravelle/jcodemunch-mcp

v1.108.193 - the size cap can be raised, and a withheld file cannot prove absence

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 16:50

Both reported by @dkiaulakis, measured on 1.108.188. Every line he cited was verified before anything was touched: 5 for 5.

Added: JCODEMUNCH_MAX_FILE_SIZE

The per-file size cap was the one limit of three with no way to move it. DEFAULT_MAX_INDEX_FILES and DEFAULT_MAX_FOLDER_FILES each had a resolver reading config; the size cap had none, appeared in none of the 79 JCODEMUNCH_* variables, was not among index_folder()'s 11 parameters, and was passed from index_folder.py:1490 as a hardcoded constant.

New security.get_max_file_size(), same shape as its siblings, plus a max_file_size config key.

The default is unchanged at 500KB. The ask was an escape hatch, not a higher default.

Fixed: a file refused for size no longer lets the corpus call itself complete

His sentence is the actual finding:

Size is the one exclusion reason where the file is real, current, and wanted.

It is worse than reported. too_large is refused during discovery, so the file never reaches files_accepted, and the completeness reconciliation added in 1.108.176 balances perfectly — it compares what the walk accepted against what got indexed, and the refused file is outside both numbers. Coverage reported complete: true over a corpus missing a real, current, wanted source file. The contract was answering a question about the walk while presenting the answer as being about the tree.

Exclusions now split in two:

  • excluded — the corpus being defined. A .png is binary, a vendored tree is gitignore, a lockfile is wrong_extension. Never candidates. A search over this can still prove absence, and must, or no real repository could prove anything.
  • withheld — real source, current, wanted, refused by one of our limits: too_large, file_limit, unreadable. Any of these makes complete false and refuses absence claims.

The refusal names the cause and the remedy, separately from the generic partial message, because they send a reader to different places. "The index did not cover the whole tree" sends someone to inspect their repository; naming too_large sends them to a setting.

Existing indexes will start reporting complete: false after a re-index if they withheld anything, and affected absence claims will degrade. That is the contract reporting something true it was previously hiding, not a regression.

Tests

6204 passed, 7 skipped. New tests/test_v1_108_193.py (15), including the boundary that keeps the signal useful: structural exclusions alone must still permit absence.

One existing test was corrected. It set a low cap by patching DEFAULT_MAX_FILE_SIZE, which the resolver no longer consults, so the patch silently did nothing and the watcher fast path indexed an oversize file. It now sets the config value — a stronger test than before, and its failure is what proved the fast path honours the new route end to end.

No tool-count, INDEX_VERSION or schema change.

v1.108.192 - #377 P3: one immutable snapshot, start to finish

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 15:48

The two edges @mightydanp pinned adversarially on #377 after reviewing 1.108.183 through .188. Both turned out to be the same defect wearing different clothes: the pipeline read the evidence more than once and assumed the reads agreed.

Fixed: an absence receipt no longer resolves through a mutable key

A receipt has a full snapshot-bound SHA-256 identity. The absence record it linked to did not. The absent:<sha12> key is sha256(tool, repo, query, scope)[:12] with no snapshot in it, so re-running the same query over a different tree overwrites the record at the same key, and finalization followed absence_ref back to whatever sat there at validation time.

Both directions were reachable, and both now have a regression:

  • a receipt minted over a provable scan refused, because a later scan of the same query went stale — destroyed proof;
  • a receipt minted over a stale scan attested, because a later scan was clean — borrowed proof, and the dangerous one.

The producer now freezes a deep copy of the scan into the envelope, and validation runs the refusal rules over that. absence_refusal remains the only implementation of those rules; what changed is the input it is handed, not the rules. Receipts minted before this release fall back to the legacy lookup rather than becoming uncitable.

Fixed: validation, rendering and hashing consume one resolution

_validate_evidence resolved each envelope, then _render_receipt_detail performed its own receipts.lookup. The store is bounded and LRU, so a receipt evicted between the two produced a body with no receipt block, and a sha256 computed over that body, while the attestation said the evidence was proved. The rendered artifact and its own receipt disagreed.

render_handoff now takes the map validation already built. The regression asserts on the seam as well as the symptom, so it holds if the eviction policy changes.

Changed

This is a published-schema change and is stated as one. schemas/evidence-receipt.schema.json sets additionalProperties: false, so absence_record had to be added there. Additive and optional: a receipt minted before this release validates unchanged, and the schema string stays jcodemunch.evidence/v1 because nothing required changed and no existing field changed meaning.

One shipped behaviour changed. Editing the live _absences record can no longer re-judge a receipt already minted from it. A v1.108.183 test expressed "this scan became unciteable" by mutating that record after the mint; that technique was never valid, and its invalidity is precisely the fix, since an in-place edit is indistinguishable from a different scan overwriting the same key. The test's actual claim is unchanged and now asserted at mint time, and a new test pins the changed semantics directly.

This does not settle whether a receipt should expire because the tree moved on after minting. That is a different axis, it is the expiry taxonomy, and it remains open under Phase 2 P3.

Tests

6189 passed, 7 skipped. New tests/test_v1_108_192.py (10). Every fix verified non-vacuous by reverting it: 3 failures on the absence half, 2 on the resolution half. No tool-count or INDEX_VERSION change.

v1.108.191 - the eager auto-watch path indexes once

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 14:29

Found while porting @Bortlesboat's #388. Both arms of _auto_watch_if_needed indexed twice, and neither was the bug #384 reported. One of them is older and wider than #384 ever was.

Fixed

The takeover arm ran two concurrent indexes on the same folder.

maybe_takeover returning started spawns a watch task whose initial index runs as a concurrent asyncio task. The caller then awaited ensure_indexed on the same folder. Two writers, one indexwrite lock, and every acquire passes wait_seconds=60.0. This is the race the entire #384 discussion kept warning about as a reason not to fix things a certain way, and it was already in the code on a different path.

The fall-through arm walked the tree twice.

ensure_indexed was awaited, then add_folder started a watch task that walked the same tree again. Serialized, so not a race, but a redundant full walk on every eager auto-watch — any tool touching an unwatched repo, not only index_folder.

Both collapse to one restructure: index once via ensure_indexed, awaited, then adopt that index. maybe_takeover and add_folder are both told to skip.

Ordering is the fix, not just the flag, and there is a test on the call order. ensure_indexed must complete before any watch task starts, or the task builds its hash cache from an index that is about to be rewritten underneath it. The previous code started the task first.

ensure_indexed is the pass that was kept because it is the only one that is both awaited (so the tool runs against fresh data, which is this hook's entire purpose) and race-safe through the manager's _pending coordination.

Changed

record_index_ready is now a separate flag from skip_initial_index.

v1.108.189 had the skip path always write a synthetic reindex record. That was right for its only caller and wrong the moment a second one appeared, because the two differ: the index_folder tool indexes but writes no reindex record, so the watcher must record readiness on its behalf or get_watch_status never learns the index is current; ensure_indexed writes a real record carrying the real result, and a synthetic one over it would replace a measurement with a placeholder.

One flag could not serve both without either losing the signal or clobbering it.

Tests

6178 passed, 7 skipped. New tests/test_v1_108_191.py (9), including the call-order pin, both directions of the record/skip split, and a guard that the two internal standby-loop callers still pass no flags — they take over precisely so the folder gets indexed. No tool-count, INDEX_VERSION or schema change.

v1.108.190 - the takeover half of the double index

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 13:49

Credit for this release goes to @Bortlesboat, who found it independently in #388 while fixing #384. Their PR and v1.108.189 landed the same day and reached the same design. Theirs covered a path ours did not.

Fixed

maybe_takeover skips the initial index on the deferred path.

v1.108.189 stopped index_folder double-indexing by deferring registration and passing skip_initial_index to add_folder. It left maybe_takeover alone. So when another process had been watching the folder and this one adopted it, the watch task still ran a full pass over the tree the tool had just finished indexing, and the exact bug #384 reported survived for that case.

Where the two fixes differ: they registered the watch before dispatch and hydrated the hash cache lazily; we defer registration to after dispatch, which also closes the window where a file edited mid-index could fire a debounced reindex into the tool's in-flight index on the indexwrite lock. Both halves were right about different things.

The default stays False, with a test pinning it. The standby signal loop and the fallback retry loop both call maybe_takeover when nothing else is indexing, and there the initial index is the entire point of taking over.

The hash cache hydrates lazily when the eager build never ran.

Also theirs, and it fixes something older than either #384 fix. The cache is built only on a successful initial index, so a failed one left it empty for the life of the watch task. An empty cache does not raise. Every subsequent WatcherChange just loses its old_hash, which is the quiet kind of wrong.

Guarded on an explicit _hash_cache_built flag rather than if not _hash_cache, so a legitimately empty index does not re-read the store on every change.

Tests

6169 passed, 7 skipped. New tests/test_v1_108_190.py (7), including the default-drift pin and a non-vacuity check that the lazy guard is reachable. No tool-count, INDEX_VERSION or schema change.

v1.108.189 - the two residual #375 sub-problems, closed

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 12:28

Both #383 and #384 were filed as decisions not taken rather than bugs not found. Neither was waiting on evidence; each was waiting on someone to pick between options with real downsides. Picked.

Fixed: index_folder no longer indexes its own path twice (#384)

_AUTO_WATCH_EXCLUDED lists the tools the pre-dispatch auto-watch hook must not act on. index_file was in it; index_folder was not. So with watch enabled, one index_folder(path=X) call ran a complete ensure_indexed(X) inline and then indexed X again. One user-visible call, two full passes over the same tree - silent, because that first pass logs below WARNING and carries no progress reporter.

Fixed with the third option the issue named and nobody had explored: index_folder is deferred, not excluded. The hook returns the folder instead of indexing it, and _auto_watch_after_tool registers the watch in the dispatcher's finally, after the tool's own index is on disk.

Both obvious fixes were rejected, and the reasons are pinned by tests. Excluding it outright silently removes auto-start-watching from the most natural way a user asks for a folder to be watched, a behaviour removal under the 1.x zero-surprise contract. Dropping only the redundant ensure_indexed lets the watch task's initial index race the tool's index on the same indexwrite lock, where every acquire passes wait_seconds=60.0 - trading duplicate work for minute-long waits. Deferring races nothing.

The skip path still builds the hash cache, and still records who made the index fresh, because get_watch_status has no other way to learn it and presenting the caller's work as the watcher's own would be its own small dishonesty.

Added: an elapsed-time heartbeat when the client sends no progressToken (#383)

The MCP spec makes progressToken the client's opt-in, so emitting notifications anyway is a protocol violation and was never available. Combined with the default log_level of WARNING, a long run on a token-less client emitted nothing at all, and an idle-timeout kill read as a crash.

New HeartbeatReporter puts the signal on a channel the client already sees. Two choices that cut against the obvious version:

  • WARNING, not INFO. #375 sub-problem B closed as not-a-defect precisely because the default level is WARNING. A heartbeat at INFO fixes nothing.
  • Nothing before the first interval elapses (JCODEMUNCH_HEARTBEAT_SECONDS, default 30). Fast runs stay byte-for-byte as quiet as before, and a run that never emitted a heartbeat gets no completion line either.

It holds no notify channel and no session reference by construction, and its close() yields no futures, so it cannot become an unrequested notification. Pinned by tests rather than left as intent.

Changed: the roadmap moves out of the issue tracker

Phases 5 and 6 of the evidence arc (#385, #386) are accepted design with no start date and an unmet dependency - a plan, not a problem to fix or a feature being built. They now live in ROADMAP.md with their close conditions verbatim and credit intact.

Standing convention going forward: an issue opens when work starts or when a user is blocked.

Tests

6162 passed, 7 skipped. New tests/test_v1_108_189.py (29) plus 2 dispatcher-level heartbeat pins in test_server.py. No tool-count, INDEX_VERSION or schema change.

v1.108.188 - telemetry rows land in the store the caller named

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 01:48

The second finding 1.108.186 reported and 1.108.187 left open.

Writer and reader disagreed about which database they meant

Every reader of the perf database takes a base path — ranking_db_query, WeightTuner, analyze_perf. The writers passed none. Writes resolved through _State._base_path, which is whatever the first caller of _ensure_loaded happened to pass, and most savings writes pass nothing. So it was usually None, and every telemetry row landed in ~/.code-index no matter which storage_path the tool was handed.

Measured rather than argued: with a fresh tracker state and a boxed home, five producers wrote five rows to the default store while ranking_db_query(base_path=<named store>) returned zero.

WeightTuner on a non-default store was therefore learning from a ledger the searches had never written to. On a default install the writer and the reader coincide, which is why this stayed invisible for so long — and it is also how a reproduction earlier in this arc put rows in a developer's real telemetry db while every tool was pointed at a temp directory.

Both tables in that file had it

ranking_events now takes base_path at all six producer call sites — search_symbols lexical, semantic and fusion, get_ranked_context main and fusion, and plan_turn — through the new tools._utils.ledger_base_path(store). It returns None when a store cannot name itself, which restores the previous default rather than dropping the row.

tool_calls takes it from the dispatcher, which already extracts repo from the same arguments. analyze_perf reads both tables through one base path, so fixing one and not the other would have left the tool half blind.

An explicit base path is not cached. It belongs to one call, not to the process: caching it would make the second store in a session silently write to the first one's database. The default path keeps its cache.

_perf_db_failed is a process-wide kill switch and now covers the default database only. It was written when there was exactly one path it could mean. One unwritable caller-supplied store must not take telemetry down for every other store in the process.

What is deliberately not changed

Where the savings total lives. _ensure_loaded still pins _base_path on first call. That counter is a process-global lifetime total loaded once from disk, and re-anchoring it mid-process would move a user's cumulative savings between files. Telemetry rows are per-call facts about a named store; the savings total is not. A test pins the asymmetry so it reads as a decision rather than an oversight.

Nine of this arc's own tests were wrong, and that is part of the finding

⚠ Tests from 1.108.186 and 1.108.187 put the ledger in a different directory from the storage path and captured rows only because writes ignored storage_path. They now point at the store, which is where rows actually go. Both releases' pins were re-verified as non-vacuous under the corrected fixtures.

The practical effect: a test that writes ranking events no longer needs _state._loaded = True to stay off the developer's real telemetry.db, because it no longer routes through _base_path at all.


New tests/test_v1_108_188.py (14). Reverting the routing fails six of them. Compatibility pinned: a call with no base_path still uses the default store. Suite 6153 passed plus the known 12 local-ONNX environment failures; Tests 9/9 and Replay green before publishing (tests/test_tools.py::TestWindowsUNCPathSafety failed once on windows-3.11 and passed on a re-run of the identical commit — it performs a real \\server\share lookup, so its outcome depends on the runner's network stack). No tool-count change, no INDEX_VERSION change, no schema change.

Design context: #377, proposed by @mightydanp.

v1.108.187 - both fusion exits record the features they measured

Choose a tag to compare

@jgravelle jgravelle released this 27 Jul 00:40

The first of the two adjacent findings 1.108.186 reported instead of folding in.

A default is not a measurement

get_ranked_context's fusion exit passed no extract_ledger_features payload at all, so top1_score, top2_score and identity_hit were recorded as their defaults on every ranking-ledger row it ever wrote. Three of regret's six signals read those columns, and once a default is in the ledger nothing downstream can tell it from a measurement.

search_symbols' fusion exit had the narrower half of the same defect: it passed the features but built their input as {"score": s} with no identity key, so identity_hit was False on every fusion row regardless of what the identity channel found. Both are fixed together — shipping one honest producer and one false one would have guaranteed a third release on the same column.

Identity comes from the identity channel's own raw_scores. Both exits build that channel, so they know per symbol whether identity matched, and build_identity_channel admits only symbols scoring above zero.

The part a failing test found, not a reading

compute_confidence sniffs the same identity key. When no has_identity_match is passed it scores identity 1.0 known-true / 0.7 unknown / 0.6 known-false — so feeding identity through the input attach_confidence already receives would have moved the confidence of every fusion search. Measured at 0.742 → 0.783 on one fixture.

That is a ranking-adjacent behaviour change with its own justification, not a side effect of recording a column, so the ledger input is now separate and the confidence call keeps the input it has always had. The separation is pinned behaviourally rather than by reading the source: attach_confidence must receive rows without an identity key on the same call where the ledger features receive rows with one.

The history, and why this discriminator is weaker on purpose

New ledger_trust.identity_label_is_trustworthy. Unlike the semantic rule shipped in 1.108.186, identity_hit has no value a pre-fix row alone can carry — pre-fix is always 0, and 0 is also a perfectly honest post-fix answer, so matching on the column would discard good evidence forever. What is exact: a row that returned symbols while recording no top1_score can only have come from the exit that passed no features. Post-fix a non-empty result always carries a fused score, and an empty result carries no returned_ids to match on.

search_symbols_fusion's history is not separable, and no heuristic is used. It always recorded top scores and only omitted the identity key, so its pre-fix rows are indistinguishable from honest post-fix rows that had no identity match. There is no version or timestamp column to bound it by. The recency window is the only remedy, and that is stated rather than papered over.

Two more things the tests caught

  • An identity guard on regret's vocabulary_gap was unreachable and was removed. That signal requires semantic_used, and a get_ranked_context_fusion row with semantic_used=1 is already refused by the 1.108.186 rule — so the rows are suppressed either way. An unreachable guard reads like protection and is not. Found by a non-vacuity test failing, not by inspection.
  • A pre-fix single-result row was clustered as thin_result on the strength of a default. That detector treats top1_score is None as thin, which reads a missing measurement as a weak one. Only the known-featureless rows are skipped; the empty-result, weak-score and other-producer cases are unchanged and pinned.

Disclosure follows the 1.108.186 pattern: events_without_ledger_features on analyze_regret, no_ledger_features per repo in analyze_perf --ledger, and identity_hits no longer counts a default as a miss. Both keys are absent — not zero — for a caller whose ledger holds no such row.


New tests/test_v1_108_187.py (24). Non-vacuity shown on both exits: reverting either producer fails four tests. A pre-fix ledger stays readable and unrewritten. Suite 6139 passed plus the known 12 local-ONNX environment failures that are green in CI; Tests 9/9 and Replay green before publishing — the replay gate matters here because the confidence separation is what kept ranking output still. No tool-count change, no INDEX_VERSION change, no schema change.

Still open from the 1.108.186 findings: token_tracker._ensure_loaded(None) on the first savings write repins the perf db to ~/.code-index even when a tool was handed a storage_path.

Design context: #377, proposed by @mightydanp.

v1.108.186 - the ledger stops claiming a channel that never ran

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 23:42

The follow-through on a deferral disclosed in 1.108.185. A one-line surface with a learned-parameter blast radius.

The response and the ledger disagreed about the same call

get_ranked_context's fusion exit recorded semantic_used=True on every ranking-ledger row while building lexical, identity and structural channels and never a similarity one. Its own _meta.channels listed the three, and since 1.108.185 its verdict reported channels.semantic: "off" — so two accounts of one call contradicted each other, and the ledger was the one that was wrong.

The flag is now derived from the channels actually built, not hardcoded to False. Hardcoding fixes today and re-arms the same trap; a derivation means a similarity channel added there is picked up with no second edit, which is the shape search_symbols_fusion already had. The verdict's semantic_channel comes off the same expression, so the two cannot diverge again.

Flipping the flag was necessary and not sufficient

The ledger is append-only. WeightTuner learns a per-repo semantic_weight by splitting rows on that column and comparing mean confidence between the groups, so the fix would have stopped new rows from lying and left the old ones steering a learned parameter for the whole 90-day recency window.

Reproduced before touching the code: 30 honest lexical rows at 0.80 confidence plus 30 mislabelled fusion rows at 0.40 walked semantic_weight from 0.5 to 0.45, on a ledger whose honest labels decline to move it at all.

The mislabelled rows are exactly identifiable, with no heuristic. They carry tool = "get_ranked_context_fusion", a value no other producer writes, so a pre-fix row is precisely that tool with semantic_used = 1 and a post-fix row is the same tool with 0. A tool-only rule would have discarded the fix's own honest rows — the ones a consumer most wants to read.

New retrieval/ledger_trust.py holds that single rule. Three consumers read the column, and three copies of the rule would have been three things to keep in sync.

What happens to the existing rows, and why

Three options, and the two rejections matter more than the choice.

A one-time corrective UPDATE was rejected on charter grounds rather than convenience: the tuner has only ever written the tuning.jsonc sidecar and suggest_corrections is read-only by charter, so rewriting rows in a user's telemetry database is a charter change, not an implementation detail. Letting the 90-day window age them out was rejected too — that is knowingly learning from a corpus we know is mislabelled.

Unknown is a third answer, not a vote for semantic_used=0. Folding those rows into the semantic-off group would be the same error mirrored, and it would move the weight just as far. They are excluded from both means, counted, and disclosed: events_semantic_label_unknown on the tuner's signals and on analyze_regret, semantic_label_unknown per repo in analyze_perf --ledger. A silent exclusion reads as "the ledger simply had nothing to say".

⚠ The exclusion expires, and a drift guard says when. It is only honest while that exit builds no similarity channel; if one is added, its semantic_used=1 rows become real evidence and the rule would start discarding good data. A test fails if the exit grows a similarity channel while the rule is still unconditional.

A third consumer, found on the way

suggest_corrections was reporting a vocabulary gap on calls where identity matching ran. Regret's vocabulary_gap signal is the conjunction not identity_hit and semantic_used, and that exit hardcoded the semantic flag and passed no ledger features at all — so identity_hit defaulted to 0 while the identity channel was one of the three it built. Every confident fusion call was a textbook match for "the agent's term doesn't match a symbol name but means one", and those clusters become config patches shown to the user.

Blast radius, stated plainly

The ledger is opt-in (perf_telemetry_enabled, default False), so most installs hold no rows at all and were never affected. Installs that enabled telemetry and used get_ranked_context(fusion=True) have mislabelled rows, and for them both the learned weight and the correction suggestions were reading a lie.


New tests/test_v1_108_186.py (22): the real-route ledger check, a non-vacuity test for the derivation (patch a channel to be named similarity and both the ledger and the verdict must follow), non-vacuity tests showing the same ledger move the weight and raise the cluster with the guard disabled, and the drift guard. Compatibility is pinned rather than asserted: a pre-fix ledger stays readable and unrewritten, and the disclosure keys are absent — not zero — for a caller whose ledger holds no mislabelled row. Suite 6115 passed plus the known 12 local-ONNX environment failures that are green in CI; Tests 9/9 and Replay green before publishing. No tool-count change, no INDEX_VERSION change, no schema change.

Design context: #377, proposed by @mightydanp.

v1.108.185 - fusion gets a verdict, and fusion can prove absence

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 22:33

The last two exits in the suite without a verdict. Every retrieval exit now carries one, pinned structurally rather than by inspection.

Fusion can prove absence, and that is the opposite of last release's answer

1.108.184 concluded that a semantic zero result cannot prove absence, because it rests on embedding geometry. This review reached the opposite conclusion for fusion, which is exactly why modes get reviewed rather than having a rule ported onto them.

build_lexical_channel and build_identity_channel each score every eligible candidate with no cap, and fuse keeps every symbol appearing in any channel. So an empty fused set means every candidate scored zero on both BM25 and identity — the same corpus fact the lexical path's absence rests on. The similarity channel can only add symbols, never remove one, so its absence weakens the ranking and cannot manufacture a false absence. That asymmetry is what the semantic exit lacked, and it is why that one carries absence_unprovable and this one does not.

Unlike the semantic exit, neither fusion exit ever asserted anything false — no verdict and no absence prose either — so a zero-result fusion search came back as a bare empty response: nothing wrong, and nothing honest.

Two defects found on the way, either of which made absent unreachable

build_structural_channel treated "restrict to nothing" as "no restriction". The guard was a falsy check, and both callers pass set(lexical.ranked_ids) | set(identity.ranked_ids). A query matching nothing produced an empty set, which skipped the filter and let every symbol with nonzero PageRank into the channel. Measured live: fusion=True on a nonsense query returned ranked rows — the whole repository, ordered by centrality — and because fusion had no verdict, nothing in the response revealed that centrality was being reported as relevance. None and set() are now different things.

A read that wrote, and mode=ro was not enough to stop it. EmbeddingStore._connect runs PRAGMA journal_mode = WAL and a CREATE-TABLE script on every connection, so the fusion exit's "does this repo have embeddings" probe wrote to the database it was reading. That bumped _db_mtime_ns, which made index_changed_since_load report rebuilding and moved_during_scan fire — so the first fusion search in any process downgraded to degraded and could not reach absent. Entirely self-inflicted, and the same defect runtime/confidence.py was fixed for.

⚠ The first fix attempt shipped nothing: read-only in SQLite means "cannot modify the database", not "cannot touch the filesystem." A plain mode=ro connection to a WAL database still creates the -wal and -shm sidecars — measured going from absent to present across a single fusion search — and _db_mtime_ns maxes over the .db and the -wal. immutable=1 is the flag that guarantees no sidecar. The trade-off it accepts is stated rather than hidden: vectors sitting in an un-checkpointed WAL go unread, so the similarity channel may be skipped just after a watcher writes embeddings. That can only weaken the ranking, because the channel adds candidates and never removes any — the opposite of what the mtime bump did.

Also in this release

  • Both fusion exits' no-candidate early returns now carry a verdict with incomplete: empty_scope (#377 item 9). The filters selected nothing, so not a byte was read, and a bare empty result reads exactly like a completed search that found nothing.
  • Both exits finally receive the pre-scan identity capture (#377 item 6) that was taken on the shared path and never handed to the branches that diverge.
  • New verdict.retrieval_verdict_for_index, sibling of the existing symbol_verdict_for_index and file_verdict_for_index wrappers. Three exits shipped with no verdict, and the reason is worth naming: adding one meant reproducing five call patterns by hand — freshness probe, rebuild check, coverage contract, movement comparison, scope-level working-tree state — so the cheap thing was to skip it and hand-roll the answer. The wrapper binds all five.
  • Both exits emit the negative_evidence block and warning string their tools' other exits already emit, gated by every gate. The same question must not get a differently-honest answer depending on which ranking mode ran.
  • The receipt registration gate fired on its own author a second time. Giving both exits a verdict made them mintable under completeness declarations written for a different operation, and the pinning tests from 1.108.183 failed again. The modes were reviewed and completeness_by_mode["fusion"] now declares what a fusion receipt actually rests on, for both producers.
  • get_ranked_context's fusion exit builds no similarity channel at all, so its channels.semantic reports off — the truth, and different from the semantic_used=True its ranking-ledger call has always recorded. Left alone deliberately: that argument feeds weight tuning, and moving a learned parameter is not this change.

New tests/test_v1_108_185.py (39): the structural-backfill defect pinned at unit and exit level, the sidecar regression pinned by filesystem assertion, and a drift guard asserting every search_mode the code emits has a declared completeness. Suite 6093 passed plus a known 12 local-ONNX environment failures that are green in CI; Tests 9/9 and Replay green before publishing. No tool-count change, no INDEX_VERSION change, no schema change.

Design context: #377, proposed by @mightydanp.

v1.108.184 - a ranking cannot prove absence

Choose a tag to compare

@jgravelle jgravelle released this 26 Jul 21:44

Closes the finding disclosed when 1.108.183 shipped.

The defect

search_symbols' semantic exit hand-rolled the legacy negative_evidence block and emitted no _meta.verdict at all, so not one of the absence gates shipped since 1.108.166 applied to it — while it printed "Do not claim this feature exists" anyway. Third instance of the class found in 1.108.179: an early return is a second implementation of the answer and inherits nothing.

Reproduced before touching code. semantic=True, token_budget=1 over a repo containing the target:

result_count:     0
_meta.truncated:  true
best_match_score: 53.774
warning:          "No implementation found for 'refresh_token'.
                   Do not claim this feature exists."
_meta.verdict:    null

A strong match reported in the same breath as its own absence, with nothing in the response able to contradict it. That is the item-1 defect — an empty response is not an empty search — fixed on the lexical path in 1.108.177 and left standing here.

A ranking cannot prove absence

The exit now builds the same verdict as the lexical one, so every gate applies: stale, rebuilding, partial, unknown freshness, working tree, movement, packing. It also finally receives the pre-scan identity capture (#377 item 6), which was taken on the shared path and simply never handed to the branch that diverges.

New verdict.absence_unprovable carries the part that is not a bug fix but a boundary. The lexical path's absence rests on a corpus fact: no symbol in the index contains any query term. An embedding ranking's zero result rests on embedding geometry, and a symbol can sit in the corpus while scoring at or below zero against the query vector. That is a statement about the model, not about the repository, so a semantic or hybrid zero result is degraded and can never reach absent — at any freshness, at any coverage.

It is expressed as a downgrade, so handoff.absence_refusal does the refusing off the existing "only absent proves absence" rule with no second rule to keep in sync. It is checked last among the degraded gates, because every gate above it names a condition the caller can act on: the token_budget=1 case above now reports the budget as its note and carries absence_unprovable alongside.

A refused scan now reaches a default install

Every absence gate since 1.108.166 works by downgrading to degraded, and the in-band disclosure was gated on the state still reading absent. So the better a gate worked, the less the caller was told — a refused zero-result came back as a bare empty response, and since jcodemunch ships meta_fields: [] by default, with no verdict either.

New verdict.absence_refused is set by the verdict, the only party that knows both that the result was empty and that the claim was refused. The dispatcher uses it to attach the reason in band. The alternative was a hand-kept tuple of per-tool result keys, which is the class of bug that silently stops covering the next producer somebody adds.

The receipt gate caught its own author

Giving the semantic exit a verdict made it mintable — under a completeness declaration reading "lexical BM25 over the inverted index", which is false for an embedding ranking. The pinning test from 1.108.183 failed, the mode got reviewed, and hybrid / semantic_only now declare their own completeness through Producer.completeness_by_mode. A capability that reads enforced and is not is the failure Phase 2 exists to prevent, so the gate catching this is the design working rather than a nuisance. A mode absent from that table falls back to the default, so adding one without reviewing it is a visible omission instead of a silent inheritance.

semantic, semantic_only and semantic_weight join the receipt's mode_args, and the projector records the exit's own _meta.search_mode: a semantic and a lexical search of one string are different operations and must not collide on one evidence id.

Deliberately not done

The two fusion exits still carry no verdict. They assert nothing false — no verdict and no absence prose either — so nothing there is lying to anyone. Giving them one means first deciding whether a Weighted-Reciprocal-Rank ranking that includes a lexical channel can prove absence, and that is its own review rather than a rider on this one. Pinned by a test so the decision cannot be made by accident.

A genuine semantic zero-result now returns no negative_evidence and no warning string, because the only sentence the old block could produce was one it had no standing to make. The reason rides the verdict and, on a default install, the re-attached carrier.


Both new blocks published in schemas/retrieval-verdict.schema.json in the same commit that emits them. New tests/test_v1_108_184.py (31), including the reproduction pinned and the lexical path proven still able to reach absent — a gate has to be scoped to the mode that earned it. Suite 6057 passed plus a known 12 local-ONNX environment failures that are green in CI; Tests 9/9 and Replay green before publishing. No tool-count change, no INDEX_VERSION change.

Design context: #377, proposed by @mightydanp.