answer_provenance names a commit the INDEX has not reached, so a watcher-lag miss and a measured absence are indistinguishable #260

Closed
opened 2026-09-10 21:13:49 +02:00 by buildagent · 2 comments
Member

What happened

Live, during the Lane P merge verification on 5b14a79.

I merged, then ~25 s later asked:

search_text(query: "COSI_ATTRIBUTION_REPO", path_glob: "crates/indexer/tests/*")

The reply was total: 0, with:

"empty_population": { "basis": "measured", "unfiltered_total": 0,
  "filters": [{ "arg": "path_glob", "value": "crates/indexer/tests/*",
                "total_without": 0, "alone_emptied_the_result": false }] },
"answer_provenance": { "build": "0.27.1 (1d3228e)",
  "indexed_trees": { "primary": { "head": "5b14a79368d2",
                                  "uncommitted_changes": false,
                                  "sibling_worktrees": 3 } } }

grep -c on disk at that moment: 4. Twenty-five seconds later the identical
query returned those 4 matches (lines 26, 73, 123, 202) plus CLAUDE.md:89.

Both of the matching files are files the merge had just written. The index had
not caught up; the daemon was under a cargo test --workspace run and two
concurrent corpus indexes.

Why this is a finding and not just watcher lag

The lag is expected and documented. What is not defensible is that every
disclosure beside the zero read clean
:

  • answer_provenance.indexed_trees.primary = head: 5b14a79368d2,
    uncommitted_changes: false — i.e. "the tree I read is exactly this commit,
    with nothing uncommitted"
    . It was not. The content answered from was
    pre-merge.
  • empty_population.basis: "measured" — which
    docs/answer-provenance.md itself calls "this server's strongest claim …
    the only zero that is evidence of absence"
    (docs/reason-codes).

So the strongest claim in the product was false, and the block whose title is
"which build answered, which tree it had read" asserted the wrong tree.

head and uncommitted_changes are both read from git. Nothing in the
block is derived from the index, so no field in it can move when the index is
behind. sibling_worktrees: 3 was the only hint anything was off, and it is
about a different hazard entirely (#220/#238).

This is exactly the shape #182 forbids, one level deeper. The doc's rule is:

A commit is never reported without one of the other two keys beside it.
Naming a commit for a tree that has uncommitted changes asserts a state that
does not exist, and does it more authoritatively than saying nothing.

The block guards working tree vs commit. It has no guard for index vs
working tree
— and that is the gap an agent hits at exactly the worst moment:
right after it wrote the file it is asking about.

Repro

  1. Have the daemon running and busy (a cargo test --workspace is enough).
  2. git merge or write a file introducing a literal that appears nowhere else.
  3. Immediately search_text for that literal.
  4. Observe total: 0, basis: "measured", and an answer_provenance naming
    the new HEAD with uncommitted_changes: false.
  5. Re-ask after the watcher settles — the hits appear.

The window widens with load, which is when an agent is most likely to be
mid-task.

Shape of a fix

The machinery already exists: the I034 freshness barrier computes staleness,
doctor reports index freshness, and index_health is per-file. None of it
reaches answer_provenance.

Add a fourth key to each indexed_trees entry, three-state, following
sibling_worktrees' own measured rule that agreement emits nothing (an
always-emitted 0 there cost the plugin-wpf bench 578 tokens / 5.2 %):

shape meaning
key absent, beside a head the comparison ran and the index is level with the working tree
index_lag: { files: <n>, oldest_unindexed_age_ms: <n> } the index is behind by a counted, non-empty set — this reply may be short
index_lag_unmeasured: "<why>" the probe ran and failed, so no number is reported, least of all a 0

And on a basis: "measured" zero, empty_population.unsearched must narrow
its quantifier in the same breath it makes the claim, the way #238 made it do
for other_checkouts — a measured absence taken against a lagging index is
not an absence.

Anti-vacuity for the test

A test that writes a file and asserts the disclosure fires must prove it could
have not fired: assert the key is ABSENT on a settled index over the same
tree, then write and assert it PRESENT with a non-zero files. A test that
only ever sees the busy case grades nothing — and a test that asserts on a
quiet single-file tree will never reproduce the lag at all, which is the
corpus_watcher_replay path, not a unit test.

Notes

  • Found by dogfooding on the merge that was being verified, i.e. by the tools
    answering about their own repository — which is where CLAUDE.md says such a
    gap is a finding about the product, not an inconvenience.
  • Related: #181 (which build), #182 (which tree state), #220 (is there another
    tree), #238 (the zero says so).
## What happened Live, during the Lane P merge verification on `5b14a79`. I merged, then ~25 s later asked: ``` search_text(query: "COSI_ATTRIBUTION_REPO", path_glob: "crates/indexer/tests/*") ``` The reply was `total: 0`, with: ```json "empty_population": { "basis": "measured", "unfiltered_total": 0, "filters": [{ "arg": "path_glob", "value": "crates/indexer/tests/*", "total_without": 0, "alone_emptied_the_result": false }] }, "answer_provenance": { "build": "0.27.1 (1d3228e)", "indexed_trees": { "primary": { "head": "5b14a79368d2", "uncommitted_changes": false, "sibling_worktrees": 3 } } } ``` `grep -c` on disk at that moment: **4**. Twenty-five seconds later the identical query returned those 4 matches (lines 26, 73, 123, 202) plus `CLAUDE.md:89`. Both of the matching files are files the merge had just written. The index had not caught up; the daemon was under a `cargo test --workspace` run and two concurrent corpus indexes. ## Why this is a finding and not just watcher lag The lag is expected and documented. What is not defensible is that **every disclosure beside the zero read clean**: - `answer_provenance.indexed_trees.primary` = `head: 5b14a79368d2`, `uncommitted_changes: false` — i.e. *"the tree I read is exactly this commit, with nothing uncommitted"*. It was not. The content answered from was pre-merge. - `empty_population.basis: "measured"` — which `docs/answer-provenance.md` itself calls *"this server's strongest claim … the only zero that is evidence of absence"* (`docs/reason-codes`). So the strongest claim in the product was false, and the block whose title is *"which build answered, **which tree it had read**"* asserted the wrong tree. `head` and `uncommitted_changes` are both read from **git**. Nothing in the block is derived from the index, so no field in it can move when the index is behind. `sibling_worktrees: 3` was the only hint anything was off, and it is about a different hazard entirely (#220/#238). This is exactly the shape #182 forbids, one level deeper. The doc's rule is: > **A commit is never reported without one of the other two keys beside it.** > Naming a commit for a tree that has uncommitted changes asserts a state that > does not exist, and does it more authoritatively than saying nothing. The block guards *working tree vs commit*. It has no guard for *index vs working tree* — and that is the gap an agent hits at exactly the worst moment: right after it wrote the file it is asking about. ## Repro 1. Have the daemon running and busy (a `cargo test --workspace` is enough). 2. `git merge` or write a file introducing a literal that appears nowhere else. 3. Immediately `search_text` for that literal. 4. Observe `total: 0`, `basis: "measured"`, and an `answer_provenance` naming the new HEAD with `uncommitted_changes: false`. 5. Re-ask after the watcher settles — the hits appear. The window widens with load, which is when an agent is most likely to be mid-task. ## Shape of a fix The machinery already exists: the I034 freshness barrier computes staleness, `doctor` reports index freshness, and `index_health` is per-file. None of it reaches `answer_provenance`. Add a fourth key to each `indexed_trees` entry, three-state, following `sibling_worktrees`' own measured rule that **agreement emits nothing** (an always-emitted `0` there cost the plugin-wpf bench 578 tokens / 5.2 %): | shape | meaning | |---|---| | key absent, beside a `head` | the comparison ran and the index is level with the working tree | | `index_lag: { files: <n>, oldest_unindexed_age_ms: <n> }` | the index is behind by a counted, non-empty set — this reply may be short | | `index_lag_unmeasured: "<why>"` | the probe ran and failed, so **no number is reported**, least of all a `0` | And on a `basis: "measured"` zero, `empty_population.unsearched` must narrow its quantifier in the same breath it makes the claim, the way #238 made it do for `other_checkouts` — a measured absence taken against a lagging index is not an absence. ## Anti-vacuity for the test A test that writes a file and asserts the disclosure fires must prove it could have *not* fired: assert the key is ABSENT on a settled index over the same tree, then write and assert it PRESENT with a non-zero `files`. A test that only ever sees the busy case grades nothing — and a test that asserts on a quiet single-file tree will never reproduce the lag at all, which is the `corpus_watcher_replay` path, not a unit test. ## Notes - Found by dogfooding on the merge that was being verified, i.e. by the tools answering about their own repository — which is where CLAUDE.md says such a gap is a finding about the product, not an inconvenience. - Related: #181 (which build), #182 (which tree state), #220 (is there another tree), #238 (the zero says so).
Author
Member

Relationship to #245 — adjacent, and this one disproves its carve-out

#245 is the same block and the same axis, and the two must not be read as
duplicates. Stated precisely:

#245 this
what disagrees two replies came from different generations of the same tree the index is behind the working tree
what the caller sees both replies succeed, rows differ, provenance byte-identical one reply is a basis: "measured" zero
the missing fact which generation answered whether the index had caught up

#245 explicitly sets this case aside:

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.

That carve-out does not hold, and this issue is the measurement. The
reply here carried no staleness disclosure of any kind. index_stale is
the I034 freshness barrier, which gates changed_symbols and
review_diff; search_text has no such door and emitted nothing. So
"the watcher is behind" is disclosed on some tools and silent on the
one whose answer was a counted absence.

Both fixes land in the same struct, and doing them together is cheaper
than doing either twice: indexed_trees[<project>] gains the generation
(#245) and an index-lag disclosure (this), both three-state, both
absent-on-agreement. The generation integer alone does not close this
one — it makes two replies comparable but still lets a single
basis: "measured" zero assert an absence the index could not have
seen.

The headroom constraint #245 records applies here unchanged: this must
be an absent-when-level key, not a per-reply paragraph.

## Relationship to #245 — adjacent, and this one disproves its carve-out #245 is the same block and the same axis, and the two must not be read as duplicates. Stated precisely: | | #245 | this | |---|---|---| | what disagrees | two replies came from **different generations** of the same tree | the index is behind the **working tree** | | what the caller sees | both replies succeed, rows differ, provenance byte-identical | one reply is a `basis: "measured"` **zero** | | the missing fact | *which generation* answered | *whether the index had caught up* | #245 explicitly sets this case aside: > **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. **That carve-out does not hold, and this issue is the measurement.** The reply here carried no staleness disclosure of any kind. `index_stale` is the I034 freshness barrier, which gates `changed_symbols` and `review_diff`; `search_text` has no such door and emitted nothing. So "the watcher is behind" is disclosed on *some* tools and silent on the one whose answer was a counted absence. Both fixes land in the same struct, and doing them together is cheaper than doing either twice: `indexed_trees[<project>]` gains the generation (#245) and an index-lag disclosure (this), both three-state, both absent-on-agreement. The generation integer alone does not close this one — it makes two replies *comparable* but still lets a single `basis: "measured"` zero assert an absence the index could not have seen. The headroom constraint #245 records applies here unchanged: this must be an absent-when-level key, not a per-reply paragraph.
Author
Member

Released in v0.28.1, build 340a75a, via #264 and #265. Release CI passed, including the Windows archive round-trip smoke test. Freshness is a bounded working-tree observation with explicit lag/incompleteness, rather than a claim that git HEAD identifies indexed content. The installed public binaries passed both arms: a settled text miss reported metadata agreement; after adding a text-only file, the unchanged zero reported lag with added > 0 and narrowed empty_population scope to say working-tree absence was not established. The daemon subsequently indexed the new text and a later symbol edit. Observation timestamps were checked across the cache refresh. 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. Freshness is a bounded working-tree observation with explicit lag/incompleteness, rather than a claim that git HEAD identifies indexed content. The installed public binaries passed both arms: a settled text miss reported metadata agreement; after adding a text-only file, the unchanged zero reported lag with added > 0 and narrowed empty_population scope to say working-tree absence was not established. The daemon subsequently indexed the new text and a later symbol edit. Observation timestamps were checked across the cache refresh. 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#260
No description provided.