index: pagination cursors carry no active-generation fingerprint (#78 residual) #127

Closed
opened 2026-09-04 20:54:03 +02:00 by buildagent · 1 comment
Member

Split out of #78. Its two sentences — "cursors include active-generation fingerprint and invalidate cleanly after promotion" and "cached repo maps/context packs cannot mix epochs" — are the last unclosed part of #78's "Resolution and ids" section.

Status: OPEN, and the recorded deferral rationale was STALE on both its premises

crates/daemon/src/epoch.rs (module doc, ~lines 201-230) recorded why S49 deferred this. Both load-bearing claims were re-measured on 2026-09-04 and both are false. The doc has been corrected in the tree; this issue carries the measurement.

1. "until something in production calls promotion::promote, the epoch cannot move inside a session at all, and a fingerprint over a constant invalidates nothing."

False since v0.23.0. crates/cli/src/activation.rs calls promotion::promote at two production sites, landed by #80 S17 (commit 1a6a431, git describe --contains → v0.23.0~55). find_callers(promote, exclude_tests=true) returns 4 production callers.

2. "the MCP layer does not KNOW the epoch. It reaches that layer only inside stats, which is a full-table count sweep, so stamping a cursor would cost one of those per paginated call."

False. index_coverage has carried it since S49 via local_index::FileClaim::active_generation, which is a per-path read, not a count sweep. ReadEpoch::probe is MEASURED by its own bench_read_epoch_probe at 8.5-10.2 µs, against a search_symbols p50 of ~10.4 ms — 0.08-0.10% of one tool call. The cost objection is wrong by three orders of magnitude.

What is genuinely left is the wire change to two paginated replies, which is real work and is why this is its own issue rather than a line in a residual sweep.

The gap, precisely

Two cursor dialects, neither carrying an epoch:

  • numeric (project pinned) — mcp-server's parse_cursor, s.parse::<usize>(). A bare offset, invalidated by nothing, including the watcher reindex that happens every second.
  • fan-out — FanCursor / fan_fingerprint, fan:v2:<hex8>:<name>=<n>|…. The <hex8> is FNV-1a over the tool name and query-identity args only. Two production mint sites: fan_out_search_symbols (query, kind, lang, path_glob) and fan_out_search_text (query, lang, path_glob, category, whole_word). No generation, no epoch, no schema version.

crates/daemon/tests/reader_epoch_e2e.rs contains zero occurrences of "cursor". The cursor-across-promotion case is ungraded.

Why it is not benign

Promotion is not a quiet neighbour. generations::promote_in_transaction:

  • flips the gate ON where it was off — a single-generation DB has ReadEpoch::is_on() == false and reads are ungated; after promotion there are ≥2 generations, so ep.gate(..) starts emitting a predicate and the result set shrinks;
  • UPDATE refs SET target_id = NULL, resolved_by = NULL WHERE {crossing};
  • DELETE FROM symbol_edges …;
  • UPDATE symbols SET ref_count = 0 WHERE ref_count != 0 AND generation_id IS NOT ?1.

search_symbols orders by ref_count DESC, so the ordering an offset indexes into is rewritten under it. A replayed cursor skips or repeats rows and returns no invalid_cursor.

Size, stated so it is not guessed at

Cross-session, not intra-session. Both production promote paths refuse while a daemon owns the project (cli/src/activation.rs's Performed::Deferred(Owner); cli/src/doctor.rs's Recovery::Promote(g) if daemon_owns) and the daemon never promotes itself (crate::reactivate: "A re-arm creates no generation and promotes none"). The reachable window is: daemon idles out (30 min, I027) → CLI promotes → daemon respawns → an agent replays a cursor it was handed before. Plus --no-daemon.

What closing it looks like

An epoch on the paginated reply envelope, then in the fingerprint. Both skew directions need non-empty payloads (the rename lesson), and the degrade needs its own three states: absent = an older daemon that did not report, present-and-equal = verified, present-and-different = invalid_cursor with the reason.

Also unclosed by the same sentence: context_pack and repo-map caching, which #78 names in the same breath.

Split out of #78. Its two sentences — *"cursors include active-generation fingerprint and invalidate cleanly after promotion"* and *"cached repo maps/context packs cannot mix epochs"* — are the last unclosed part of #78's "Resolution and ids" section. ## Status: OPEN, and the recorded deferral rationale was STALE on both its premises `crates/daemon/src/epoch.rs` (module doc, ~lines 201-230) recorded why S49 deferred this. Both load-bearing claims were re-measured on 2026-09-04 and both are false. The doc has been corrected in the tree; this issue carries the measurement. **1. "until something in production calls `promotion::promote`, the epoch cannot move inside a session at all, and a fingerprint over a constant invalidates nothing."** False since **v0.23.0**. `crates/cli/src/activation.rs` calls `promotion::promote` at two production sites, landed by #80 S17 (commit `1a6a431`, `git describe --contains` → `v0.23.0~55`). `find_callers(promote, exclude_tests=true)` returns 4 production callers. **2. "the MCP layer does not KNOW the epoch. It reaches that layer only inside `stats`, which is a full-table count sweep, so stamping a cursor would cost one of those per paginated call."** False. `index_coverage` has carried it since S49 via `local_index::FileClaim::active_generation`, which is a per-path read, not a count sweep. `ReadEpoch::probe` is MEASURED by its own `bench_read_epoch_probe` at **8.5-10.2 µs**, against a `search_symbols` p50 of ~10.4 ms — 0.08-0.10% of one tool call. The cost objection is wrong by three orders of magnitude. What is genuinely left is the wire change to two paginated replies, which is real work and is why this is its own issue rather than a line in a residual sweep. ## The gap, precisely Two cursor dialects, neither carrying an epoch: * **numeric** (`project` pinned) — `mcp-server`'s `parse_cursor`, `s.parse::<usize>()`. A bare offset, invalidated by nothing, including the watcher reindex that happens every second. * **fan-out** — `FanCursor` / `fan_fingerprint`, `fan:v2:<hex8>:<name>=<n>|…`. The `<hex8>` is FNV-1a over the **tool name and query-identity args only**. Two production mint sites: `fan_out_search_symbols` (`query, kind, lang, path_glob`) and `fan_out_search_text` (`query, lang, path_glob, category, whole_word`). No generation, no epoch, no schema version. `crates/daemon/tests/reader_epoch_e2e.rs` contains **zero** occurrences of "cursor". The cursor-across-promotion case is ungraded. ## Why it is not benign Promotion is not a quiet neighbour. `generations::promote_in_transaction`: * flips the gate ON where it was off — a single-generation DB has `ReadEpoch::is_on() == false` and reads are ungated; after promotion there are ≥2 generations, so `ep.gate(..)` starts emitting a predicate and the result set shrinks; * `UPDATE refs SET target_id = NULL, resolved_by = NULL WHERE {crossing}`; * `DELETE FROM symbol_edges …`; * `UPDATE symbols SET ref_count = 0 WHERE ref_count != 0 AND generation_id IS NOT ?1`. `search_symbols` orders by `ref_count DESC`, so the ordering an offset indexes into is rewritten under it. A replayed cursor skips or repeats rows and returns no `invalid_cursor`. ## Size, stated so it is not guessed at **Cross-session, not intra-session.** Both production promote paths refuse while a daemon owns the project (`cli/src/activation.rs`'s `Performed::Deferred(Owner)`; `cli/src/doctor.rs`'s `Recovery::Promote(g) if daemon_owns`) and the daemon never promotes itself (`crate::reactivate`: "A re-arm creates no generation and promotes none"). The reachable window is: daemon idles out (30 min, I027) → CLI promotes → daemon respawns → an agent replays a cursor it was handed before. Plus `--no-daemon`. ## What closing it looks like An epoch on the paginated reply envelope, then in the fingerprint. Both skew directions need non-empty payloads (the rename lesson), and the degrade needs its own three states: absent = an older daemon that did not report, present-and-equal = verified, present-and-different = `invalid_cursor` with the reason. Also unclosed by the same sentence: `context_pack` and repo-map caching, which #78 names in the same breath.
Author
Member

Triage 2026-09-06: CLOSING. Both cursor dialects carry an epoch, the three states are implemented as documented, and the daemon-side promotion test that the filing said was missing now exists.

Verified against master; landed in 01a478b.

Both dialects

  • Numeric: parse_cursor now takes epoch: Option<&str> and parses <offset>@<hex8> — crates/mcp-server/src/server.rs:7078; refusal via stale_epoch_cursor at :7054; minting at :7131. The three states are documented at :7067-7077 and implemented as written: unstamped accepted always (old client), stamped-but-epoch-unreadable accepted unverified, stamped-and-different refused by name.
  • Fan-out: new fan:v3:<qfp>:<efp>:<entries> tag — server.rs:7223, parsed at :7235, minted at :7305.

The design choice worth recording: the cursor's own shape is the disclosure, so nothing was added to a reply body. An epoch mismatch names the promotion rather than sending an agent hunting a difference in its own arguments.

Runs (exit 0)

cargo test -p code-index-mcp --bin code-index-mcp -- \
  a_numeric_cursor_is_bound_to_the_generation_that_minted_it \
  a_fan_cursor_is_bound_to_the_generation_that_minted_it        → 2 passed
cargo test -p code-index-daemon --test reader_epoch_e2e -- \
  the_epoch_a_cursor_is_bound_to_moves_across_a_promotion       → 1 passed

The third is the one that matters. This issue's own evidence was "reader_epoch_e2e contains zero occurrences of cursor" — the mcp-side tests could have bound cursors to a constant and stayed green. crates/daemon/tests/reader_epoch_e2e.rs:1184 ties them to a real promotion moving a real epoch, and its doc names exactly that mutation.

Both unit tests carry RUN mutations with the RED pasted, including a positive control that the offset survives a same-generation replay — without which "refuse everything" would pass.

The trailing clause of #78's sentence, satisfied vacuously — said out loud

"cached repo maps / context packs cannot mix epochs." There is no cross-call cache. Searching crates/mcp-server/src/ and crates/daemon/src/ for cache structures turns up only two per-call HashMaps (local_index.rs:6930, graph.rs:1012). Nothing is cached across calls, so nothing can mix epochs.

Stating that plainly rather than letting it read as engineered: that requirement is met by the absence of the thing it constrains, and if a cross-call cache is ever added, this clause becomes live again and unguarded.

🤖 Triage lane, 2026-09-06, master 45cf6e4

## Triage 2026-09-06: CLOSING. Both cursor dialects carry an epoch, the three states are implemented as documented, and the daemon-side promotion test that the filing said was missing now exists. Verified against master; landed in `01a478b`. ### Both dialects - **Numeric**: `parse_cursor` now takes `epoch: Option<&str>` and parses `<offset>@<hex8>` — `crates/mcp-server/src/server.rs:7078`; refusal via `stale_epoch_cursor` at `:7054`; minting at `:7131`. The three states are documented at `:7067-7077` and implemented as written: **unstamped accepted always** (old client), **stamped-but-epoch-unreadable accepted unverified**, **stamped-and-different refused by name**. - **Fan-out**: new `fan:v3:<qfp>:<efp>:<entries>` tag — `server.rs:7223`, parsed at `:7235`, minted at `:7305`. The design choice worth recording: **the cursor's own shape is the disclosure**, so nothing was added to a reply body. An epoch mismatch names the promotion rather than sending an agent hunting a difference in its own arguments. ### Runs (exit 0) ``` cargo test -p code-index-mcp --bin code-index-mcp -- \ a_numeric_cursor_is_bound_to_the_generation_that_minted_it \ a_fan_cursor_is_bound_to_the_generation_that_minted_it → 2 passed cargo test -p code-index-daemon --test reader_epoch_e2e -- \ the_epoch_a_cursor_is_bound_to_moves_across_a_promotion → 1 passed ``` The third is the one that matters. This issue's own evidence was *"`reader_epoch_e2e` contains zero occurrences of `cursor`"* — the mcp-side tests could have bound cursors to a constant and stayed green. `crates/daemon/tests/reader_epoch_e2e.rs:1184` ties them to a **real promotion moving a real epoch**, and its doc names exactly that mutation. Both unit tests carry RUN mutations with the RED pasted, including a positive control that the offset survives a same-generation replay — without which "refuse everything" would pass. ### The trailing clause of #78's sentence, satisfied vacuously — said out loud *"cached repo maps / context packs cannot mix epochs."* There is **no cross-call cache**. Searching `crates/mcp-server/src/` and `crates/daemon/src/` for cache structures turns up only two per-call `HashMap`s (`local_index.rs:6930`, `graph.rs:1012`). Nothing is cached across calls, so nothing can mix epochs. Stating that plainly rather than letting it read as engineered: that requirement is met by the absence of the thing it constrains, and if a cross-call cache is ever added, this clause becomes live again and unguarded. 🤖 Triage lane, 2026-09-06, master `45cf6e4`
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#127
No description provided.