Two calls seconds apart served DIFFERENT index generations while answer_provenance was byte-identical: nothing says which generation answered #245

Closed
opened 2026-09-10 00:26:16 +02:00 by buildagent · 1 comment
Member

Found live by the #137/#160/#238 lane while working, not by a test. This is the #181/#182 axis one level down — those asked which binary and which tree answered; this is which generation, and nothing answers it.

Measured

Two search_symbols/search_text calls seconds apart, same session, same server:

call 1:  symbol id 788812   LinkedProjectSummary reported at server.rs:17029
call 2:  symbol id 792567   LinkedProjectSummary actually  at server.rs:17447

The first reply's line numbers were 418 lines stale for crates/mcp-server/src/server.rs.

And in both replies, answer_provenance was byte-identical:

"answer_provenance": {
  "build": "0.27.1 (1d3228e)",
  "indexed_trees": { "primary": { "head": "8a8ea5cf1502", "uncommitted_changes": false } }
}

Same binary. Same tree. Same HEAD. Same uncommitted_changes. Different generation, different symbol ids, different line numbers, and the reply says nothing.

Why this matters more than a stale read

line is the field callers feed straight into read_code. A 418-line stale span sends the reader to the wrong place — and this project has closed that exact family twice already (I052 for read_code, #202 for search_text's line/snippet divergence). Both of those were about a lying span within one reply. This is a lying span across replies, with the provenance block asserting they came from the same state.

answer_provenance's whole job is to say what produced the answer, and it currently answers two of the three questions:

question field status
which binary? build answered (#181)
which tree? indexed_trees[].head answered (#182)
which generation? — not answered

A generation is the unit the index actually reads rows from. Two generations of one tree are as different as two trees, and the machinery to say so exists — plugin_activation.active_generation is already in project_overview; it just is not in the provenance block that every reply carries.

Why this is not "the watcher is behind", which is a known and disclosed state

index_stale / pending / the reconcile disclosures all describe the index being behind the disk. This is different: both calls were served, both returned rows, and the rows came from different generations. The caller has no way to know a comparison between two of its own replies is invalid.

What must NOT be done

  • Do not pin a session to one generation. A promotion mid-session is correct behaviour and the newer answer is the better one. The defect is silence, not the change.
  • Do not add it as a per-reply paragraph. The startup payload has ~15 tokens of headroom and project_overview ~3; code_index_test_support::headroom now grades both and a re-record must attribute. This has to be small — an integer in a block that already ships.
  • Do not treat it as a cache-invalidation bug first. It may be one, but the disclosure is worth having regardless: a caller comparing two replies needs to know they are comparable, whatever the cause.
  • Do not collapse it into uncommitted_changes. That describes the tree, not the index.

Shape of a fix

answer_provenance.indexed_trees[<project>] gains the generation the rows came from — the same integer plugin_activation.active_generation already reports. Absent means the daemon did not report it (three-state, as everything else there); a value means this reply's rows came from that generation.

Then two replies are comparable iff their generations match, and a caller can tell.

Worth investigating alongside, but separable: why two calls seconds apart crossed a promotion at all, and whether the first reply's rows were from a generation already superseded when the call arrived. That is a correctness question; the disclosure is a honesty question, and the honesty half is cheap and independently right.

What a fix must prove

  • Two replies spanning a promotion carry different generation values, and two within one generation carry the same one.
  • Anti-vacuity, both arms: a daemon that cannot report it renders absent, never a reassuring 0; and a reply from a project with no generations still renders honestly rather than defaulting.
  • The cost is measured, not asserted, against the headroom mechanism.
  • Nothing else in answer_provenance moves — #181 and #182's fields are load-bearing and separately graded.

#181 (which binary answered), #182 (which tree answered), #202 and I052 (the lying-span family this extends across replies), #241 (the same "the reply cannot situate its own evidence" shape).

Found 2026-09-09/10 against master 8a8ea5c, live on code-index-mcp 0.27.1 (1d3228e).

Found live by the #137/#160/#238 lane while working, not by a test. This is the #181/#182 axis one level down — those asked *which binary* and *which tree* answered; this is **which generation**, and nothing answers it. ## Measured Two `search_symbols`/`search_text` calls **seconds apart**, same session, same server: ``` call 1: symbol id 788812 LinkedProjectSummary reported at server.rs:17029 call 2: symbol id 792567 LinkedProjectSummary actually at server.rs:17447 ``` The first reply's line numbers were **418 lines stale** for `crates/mcp-server/src/server.rs`. And in **both** replies, `answer_provenance` was **byte-identical**: ```json "answer_provenance": { "build": "0.27.1 (1d3228e)", "indexed_trees": { "primary": { "head": "8a8ea5cf1502", "uncommitted_changes": false } } } ``` Same binary. Same tree. Same HEAD. Same `uncommitted_changes`. **Different generation, different symbol ids, different line numbers, and the reply says nothing.** ## Why this matters more than a stale read `line` is the field callers feed straight into `read_code`. A 418-line stale span sends the reader to the wrong place — and this project has closed that exact family twice already (I052 for `read_code`, #202 for `search_text`'s `line`/`snippet` divergence). Both of those were about a *lying span within one reply*. This is a lying span **across replies**, with the provenance block asserting they came from the same state. `answer_provenance`'s whole job is to say what produced the answer, and it currently answers two of the three questions: | question | field | status | |---|---|---| | which binary? | `build` | answered (#181) | | which tree? | `indexed_trees[].head` | answered (#182) | | **which generation?** | — | **not answered** | A generation is the unit the index actually reads rows from. Two generations of one tree are as different as two trees, and the machinery to say so exists — `plugin_activation.active_generation` is already in `project_overview`; it just is not in the provenance block that every reply carries. ## Why this is not "the watcher is behind", which is a known and disclosed state `index_stale` / `pending` / the reconcile disclosures all describe the index being **behind the disk**. This is different: both calls were served, both returned rows, and the *rows* came from different generations. The caller has no way to know a comparison between two of its own replies is invalid. ## What must NOT be done - **Do not pin a session to one generation.** A promotion mid-session is correct behaviour and the newer answer is the better one. The defect is silence, not the change. - **Do not add it as a per-reply paragraph.** The startup payload has ~15 tokens of headroom and `project_overview` ~3; `code_index_test_support::headroom` now grades both and a re-record must attribute. This has to be small — an integer in a block that already ships. - **Do not treat it as a cache-invalidation bug first.** It may be one, but the disclosure is worth having regardless: a caller comparing two replies needs to know they are comparable, whatever the cause. - **Do not collapse it into `uncommitted_changes`.** That describes the tree, not the index. ## Shape of a fix `answer_provenance.indexed_trees[<project>]` gains the generation the rows came from — the same integer `plugin_activation.active_generation` already reports. Absent means the daemon did not report it (three-state, as everything else there); a value means this reply's rows came from that generation. Then two replies are comparable iff their generations match, and a caller can tell. **Worth investigating alongside, but separable:** *why* two calls seconds apart crossed a promotion at all, and whether the first reply's rows were from a generation already superseded when the call arrived. That is a correctness question; the disclosure is a honesty question, and the honesty half is cheap and independently right. ## What a fix must prove - Two replies spanning a promotion carry **different** generation values, and two within one generation carry the **same** one. - **Anti-vacuity, both arms:** a daemon that cannot report it renders **absent**, never a reassuring `0`; and a reply from a project with no generations still renders honestly rather than defaulting. - The cost is measured, not asserted, against the headroom mechanism. - Nothing else in `answer_provenance` moves — #181 and #182's fields are load-bearing and separately graded. ## Related #181 (which binary answered), #182 (which tree answered), #202 and I052 (the lying-span family this extends across replies), #241 (the same "the reply cannot situate its own evidence" shape). Found 2026-09-09/10 against `master` `8a8ea5c`, live on `code-index-mcp 0.27.1 (1d3228e)`.
Author
Member

Released in v0.28.1, build 340a75a, via #264 and #265. Release CI passed, including the Windows archive round-trip smoke test. The response now identifies the SQLite content actually read using its database incarnation and committed revision; it does not substitute the working-tree HEAD or an activation number for content identity. The public Linux archive was installed with the published installer, then exercised through direct and daemon MCP: stable replies agreed, a live edit changed the served snapshot, and the edited symbol became searchable. Missing-identity and inconsistent compound-read cases are covered by the regression suite. Closing the shipped disclosure fix.

Released in [v0.28.1](https://git.h-dv.de/h-dv/code-index/releases/tag/v0.28.1), build `340a75a`, via #264 and #265. [Release CI](https://git.h-dv.de/h-dv/code-index/actions/runs/752) passed, including the Windows archive round-trip smoke test. The response now identifies the SQLite content actually read using its database incarnation and committed revision; it does not substitute the working-tree HEAD or an activation number for content identity. The public Linux archive was installed with the published installer, then exercised through direct and daemon MCP: stable replies agreed, a live edit changed the served snapshot, and the edited symbol became searchable. Missing-identity and inconsistent compound-read cases are covered by the regression suite. Closing the shipped disclosure fix.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#245
No description provided.