index: pagination cursors carry no active-generation fingerprint (#78 residual) #127
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#127
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.rscallspromotion::promoteat two production sites, landed by #80 S17 (commit1a6a431,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_coveragehas carried it since S49 vialocal_index::FileClaim::active_generation, which is a per-path read, not a count sweep.ReadEpoch::probeis MEASURED by its ownbench_read_epoch_probeat 8.5-10.2 µs, against asearch_symbolsp50 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:
projectpinned) —mcp-server'sparse_cursor,s.parse::<usize>(). A bare offset, invalidated by nothing, including the watcher reindex that happens every second.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) andfan_out_search_text(query, lang, path_glob, category, whole_word). No generation, no epoch, no schema version.crates/daemon/tests/reader_epoch_e2e.rscontains 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:ReadEpoch::is_on() == falseand reads are ungated; after promotion there are ≥2 generations, soep.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_symbolsorders byref_count DESC, so the ordering an offset indexes into is rewritten under it. A replayed cursor skips or repeats rows and returns noinvalid_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'sPerformed::Deferred(Owner);cli/src/doctor.rs'sRecovery::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_cursorwith the reason.Also unclosed by the same sentence:
context_packand repo-map caching, which #78 names in the same breath.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
parse_cursornow takesepoch: Option<&str>and parses<offset>@<hex8>—crates/mcp-server/src/server.rs:7078; refusal viastale_epoch_cursorat:7054; minting at:7131. The three states are documented at:7067-7077and implemented as written: unstamped accepted always (old client), stamped-but-epoch-unreadable accepted unverified, stamped-and-different refused by name.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)
The third is the one that matters. This issue's own evidence was "
reader_epoch_e2econtains zero occurrences ofcursor" — the mcp-side tests could have bound cursors to a constant and stayed green.crates/daemon/tests/reader_epoch_e2e.rs:1184ties 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/andcrates/daemon/src/for cache structures turns up only two per-callHashMaps (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