The index discloses its root but never its commit, so an agent pinned to one worktree was served another's content and could not tell #182
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#182
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?
Found by dogfooding during the 2026-09-06 triage session that closed 22 issues. It caused a real error inside that session and was caught only by accident.
Sibling of #181 (no reply field says the answering binary is older than the tree it indexed). Same family — the answer does not disclose what produced it. #181 is the binary axis; this is the tree axis. A reader debugging a wrong answer needs both, and they should be one block rather than two unrelated fields.
Measured — the misread, exactly as it happened
The triage lane created a read-only worktree pinned to
origin/masteratf6a878a:The
code-indexMCP server, meanwhile, indexes/home/master/code/rust/cosi-mcp, which another lane advanced to45cf6e4partway through the session.A verification agent asked the index about the Windows disk pre-flight and was served:
$minFreeGb = 40floor.forgejo/scripts/windows_disk_report.ps1fileNeither exists at
f6a878a. Both are #179's fix, landed in45cf6e4. The agent had been told to verifyf6a878aand was reading45cf6e4with no indication of it.It noticed only because a test it ran contradicted a file it had already read. Without that accident, the citations would have gone into a public issue comment as facts about a commit that does not contain them.
The tool's own disclosure is what makes this sharp.
index_coverageon a triage path answers:The root is disclosed. The commit is not. The reply is precise about which directory and silent about which version of it, and the second is the one that was wrong.
We got lucky on blast radius:
git diff --stat f6a878a..45cf6e4touches only CI, workflow and test files. Had a production file moved, everyfile:linecitation in this session's issue comments would have been silently off.Mechanism
project_overview,index_coverageandlist_filesall name the project root. Nothing anywhere names the indexed tree's HEAD or its dirty state.This is not an oversight so much as an unasked question. The server instructions state, correctly and prominently, that "code-index indexes your WORKING TREE, not the last commit" — which is the product's whole point and must not change. But that makes "which commit" ill-defined rather than irrelevant, and the tree quietly treats ill-defined as not-worth-reporting.
HEADplus a dirty flag is well-defined for a working tree, and it is what a reviewer actually needs: "I am reading HEAD45cf6e4, with uncommitted changes" is both true and sufficient to catch this.Why existing gates cannot catch it
Every test indexes a fixture tree it just created. There is no second version for the answer to disagree with, so the skew is unrepresentable in the harness — structurally the same blindness as #181, and for the same reason.
Note also that the two worktree-vs-primary paths look correct at every step: relative paths resolve, absolute paths auto-route to the owning project by longest root-prefix, and
path_outside_known_rootsfires honestly when they do not. Nothing malfunctioned. The tool answered a different question from the one asked and had no vocabulary to say so.Repro
git worktree add -f --detach /tmp/probe <some older commit>Every path resolves. Every citation looks plausible. The line numbers are from the wrong tree, and no field in any reply says which tree they came from.
This is a normal configuration here, not a contrived one: multi-lane sessions with per-lane worktrees are the standing workflow, and the memory notes already record that concurrent lanes share more than the tree.
What must NOT be done
gitper call. The cost belongs at index/reconcile time, not on the read path — this repo has already refused a read-path convenience that cost a hot write path (#143).project_overview. Today's misread happened onread_codeandsearch_text, not on an orientation call. An agent that never callsproject_overviewis exactly the agent that gets caught.Relationship to #181, stated so they are not solved twice
Two different questions, one reader need:
DaemonBuild::Matchedis silent, and correctly so — it compares client⇄daemon, not binary⇄tree)Both were violated in the same session, in opposite directions: #181 served a stale binary's answer about a current tree; this served a current binary's answer about a different tree. Whoever implements either should design the block for both.
Severity argument
This is our own tool telling an agent something false, with no channel by which the agent could detect it. The product's entire pitch is that it returns targeted handles an agent can trust more than a grep — and a
file:linehandle from an undisclosed tree is worse than a grep, because grep at least ran against the bytes in front of you.🤖 Filed by the triage lane, 2026-09-06, from a live misread. Worktree
f6a878a; index45cf6e4.CONFIRMED and FIXED, as one block with #181 — you asked for that explicitly and it is one function, one key, one placement.
lane/provenance(worktree/tmp/cosi-lane-provenance, based onfc329a8).The shape
Three states for the tree, and they are an enum, not three optional fields on one struct, so "all three absent" — a
{}— is unrepresentable:{"head": "<12 hex>", "uncommitted_changes": <bool>}{"head": "<12 hex>", "uncommitted_changes_unmeasured": "<why>"}{"unavailable": "<why>"}A commit is never reported without one of the other two beside it. That is your rule, and it is enforced structurally rather than by convention.
uncommitted_changescounts staged, unstaged and untracked, because this index reads the working tree: a brand-new unstaged file is queryable, so a tree carrying one is not the commit.Your four prohibitions, one by one
"Do not report a commit for a dirty tree without saying it is dirty." Held by the enum above.
HeadOnlyexists precisely so a commit whose worktree scan did not run cannot borrow afalse."Do not shell out to
gitper call." Onegit rev-parse(O(1)) plus onegit status --porcelain(O(worktree)), cached per root behindCODE_INDEX_TREE_STATE_TTL_MS(60s default,0disables — the graders set it). A burst of tool calls costs one measurement. The lock is held across the measurement so two racing calls do not both spawn git.The two commands are deliberately not one
--porcelain=v2 --branch: keeping them apart is what makesHeadOnlya state that happens rather than one that is declared.git statusfails on its own — most often against a concurrent git holdingindex.lock— and when it does, the commit is still known and must still be reported, with the comparison's absence named beside it."Do not put it only in
project_overview." It ridesannotate_answer_provenance, above the tool router, plusread_resource.every_published_tool_carries_the_blockenumeratestools/listand checks every non-error reply, with a floor on how many it actually checked so a change that turned every reply into an error could not leave it green."Do not let absent read as clean at HEAD." Absent means this build did not report. Documented in
code-index://docs/answer-provenanceand asserted.Why it STATES instead of comparing — the part that decided the design
Every other three-state field here reports a comparison and stays quiet when it agreed. This one cannot borrow that argument, because there is nothing for the server to compare against: it does not hold your belief about which commit you are reading. That belief lived in a
/tmpworktree path nobody told it about. Your case is precisely the one where every value the server holds is correct and the answer is still wrong. So the identities are stated unconditionally and the comparison is the reader's.Grading — you named the vacuity by name
"Do not grade it with a fixture that indexes a tree it just wrote." No test here asserts a constant; every one makes the answer move:
the_reported_head_follows_the_tree— a second commit lands under the running server and the same call must name it.an_uncommitted_change_is_disclosed_beside_the_commit— a clean fixture reportsfalse(asserted, so a missing field cannot pass), then an edit and an untracked file flip it totruewhileheadstays put.two_checkouts_at_two_commits_do_not_report_the_same_tree— your repro, run: two real checkouts at different commits, two servers, and the blocks must disagree. This is the only test that fails when the head is hardcoded; every single-fixture test above stays green, which is why it exists.a_tree_that_is_not_a_checkout_says_so_rather_than_going_silent—unavailablepresent,headanduncommitted_changesboth absent.Mutations, run, all RED: cache never expires (
the_reported_head_follows_the_tree);porcelain_is_dirtyalways false (an_uncommitted_change…); untracked lines skipped (an_untracked_file_is_uncommitted_changes);measure_treeerror arms returnMeasured{head:"",false}(a_tree_that_is_not_a_checkout…);indexed_treeshardcoded (two_checkouts…);HeadOnlyrendered withuncommitted_changes:false(each_state_renders_its_own_key);parse_headwithout the hex-digit guard (only_an_oid_is_a_head— an error message on stdout must never be reported as a commit);first_linereturning""(a_reason_is_never_the_empty_string); the annotator moved intoproject_overview; the resource-leg insertion deleted.Cost, and one thing you will want to know
32 estimated tokens per reply, measured as served and confirmed independently by the agent-task benchmark at +32.4/+32.6/+32.1 per call across three corpus tiers. The ratchet bands are re-recorded with the attribution, against a clean
fc329a8rather than against the stale record. Full accounting in the #181 comment.The flag is only meaningful if
.code-index/is ignored. The daemon writes its lockfile and database there, so a checkout that does not ignore it reportsuncommitted_changes: truepermanently. This repository ignores/.code-index/; the docs page says so. Filing that as a product nit rather than fixing it here, since writing a.gitignoreinto the user's tree is a separate decision.Docs:
code-index://docs/answer-provenance.Evidence for this issue from a near-miss today, and a defect report it disproves
A triage lane reported
search_textreturningtotal: 0for terms that exist —recv_proof→ 0 while"recv_proof"→ 6,POPULATION_FLOOR→ 0 whilepopulation_floor→ 2 — and noted that without a grep cross-check it would have reported #188 and #189 as unimplemented. Two wrong verdicts, narrowly avoided.It is not a query-parsing defect, and that was established by construction rather than by failing to reproduce
I wrote a file containing two fresh tokens, waited 8 seconds, and queried both unquoted:
Underscore terms work unquoted. Uppercase underscore terms work unquoted. A newly written file is searchable within seconds. Both of the lane's suspected mechanisms are ruled out. (Probe removed; tree clean.)
Re-running its exact two queries now also returns 6 and 2 respectively.
So the remaining explanation is the one this issue is about
The zeros were almost certainly served from an index that predated the files being asked about — several lane merges had just landed in the primary checkout in quick succession, and
project_overviewwas reportingresolve_progress: "no resolve transaction has run yet in this daemon; committed counters reflect whatever the last durable index held".That is exactly #182's defect, and this is the sharpest evidence for it so far:
separator_scanassertingmatched_as: "literal substring (FTS5 trigram)"— an affirmative claim that the literal was searched.What this issue should therefore require, beyond naming the commit: a reply whose index is older than the tree it is answering about must say so in the reply, and a
total: 0from such an index must not be renderable as a measured absence.search_text'sempty_populationblock already distinguishes "the query matched nothing" from "a filter emptied it" — this is the third case, and it is the one that produces wrong conclusions rather than merely unhelpful ones.One residual I could not settle: the lane reports the quoted and unquoted forms differing in the same moment, which staleness alone does not explain. I have asked it for the raw reply, and if it cannot produce it I will record this as unreproduced on the parsing axis and keep only the staleness finding above — which stands on its own evidence.
🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
total: 0for a literal its own variant scan found in the same reply, and asserted the spellings were DISJOINT searches while behaving separator-insensitively #195FIXED in
fb37a49, merged as4f866e5, together with #181 — as this issue asked, one block rather than two unrelated fields.The index now discloses the tree head it read alongside the build hash that answered, with a dirty flag.
two_checkouts_at_two_commits_do_not_report_the_same_treegrades the axis directly, anda_daemon_that_records_no_build_reports_unavailable_not_a_matchkeeps the three states apart: absent = this build did not report, never "current".Correction to something I asserted while planning this: I claimed #195's mid-reconcile case would land in this block. It does not — that surfaces through
coverage_reasonsfrom #155, and #195 stays open on its own terms.Closing.
internal_error, not the documentedpath_outside_known_roots#201.claude/, which is permanently unindexable #238.claude/, which is permanently unindexable #238answer_provenancewas byte-identical: nothing says which generation answered #245answer_provenancenames a commit the INDEX has not reached, so a watcher-lag miss and a measured absence are indistinguishable #260code-index-plugin-hostcannot say which build it is — three of four shipped binaries answer--version, the fourth exits 21 with usage #262