bug: resolution is STICKY across incremental runs — renaming an anchor file leaves refs resolved that a cold index leaves unresolved #52

Closed
opened 2026-07-28 20:52:17 +02:00 by buildagent · 3 comments
Member

Found by the new corpus cold==incremental suite (#42) on its first real run — the first defect the OSS corpus has produced. Found at v0.8.7 @ 855710d.

Symptom

Rename Dapper/SqlMapper.csDapper/SqlMapper_renamed.cs in the pinned cs-dapper corpus repo (a semantically meaningless change: C# does not tie type names to filenames). Then:

Dapper/CommandDefinition.cs:133:41 CommandTimeout (qualifier SqlMapper.Settings)
incremental re-index Dapper/SqlMapper.Settings.cs#CommandTimeout@76
cold index of the identical tree ~UNRESOLVED~

3 refs affected in total (CommandTimeout ×2, FetchSize ×1), all in CommandDefinition.cs, all pointing at SqlMapper.Settings.cs.

The same working tree yields two different answers depending on edit history.

Repro

cp -r ~/.cache/cosi-corpus/cs-dapper work && rm -rf work/.git
code-index index --root work --db inc.db          # cold
mv work/Dapper/SqlMapper.cs work/Dapper/SqlMapper_renamed.cs
code-index index --root work --db inc.db          # incremental
code-index index --root work --db cold.db         # independent cold
# diff the id-independent ref projection -> 3 rows differ

Or: cargo test --release -p code-index-indexer --test corpus_metamorphic.

Root cause

Two mechanisms combine.

1. Resolution is sticky. Every resolve tier in crates/indexer/src/index.rs is gated on refs.target_id IS NULL (lines 765, 1170, 1454, 1587, 1605, 1683). An already-resolved ref is never re-evaluated.

2. Invalidation only tracks symbol NAMES, not evidence. The sole un-resolver is the stale-name refill (index.rs:367):

UPDATE refs SET target_id = NULL
 WHERE ... AND name IN (SELECT name FROM stale_outline_names)

stale_outline_names holds names whose symbol outline changed. But the I025 qualifier anchoring derives evidence from the file path: temp.file_keys is populated from Path::file_stem (index.rs:879), and temp.qual_anchor_files anchors a qualifier on a file whose key equals q_first (index.rs:1274-1280). So Dapper/SqlMapper.cs (stem SqlMapper) is what anchors the qualifier SqlMapper.Settings.

Renaming that file destroys the anchoring evidence — but the name CommandTimeout never became stale, because its defining file (SqlMapper.Settings.cs) was untouched. So the refs keep their stale target.

Why it only shows up here

The trigger requires anchor file ≠ target file. Measured across all 7 tier-1 corpus repos, renaming the hottest file: 0 incoherent refs everywhere. In Rust/Python/TS/JS/PHP/Ruby the anchor file usually is the target file (flags::defs::X anchors on defs.rs, where X also lives) — so renaming it re-mints X, its name goes stale, the refill fires, and coherence is preserved by accident.

C# partial classes split SqlMapper across SqlMapper.cs and SqlMapper.Settings.cs, decoupling anchor from target and exposing the gap. Any language allowing that split is affected.

Severity: medium — coherence, NOT a phantom

  • 0 dangling targets: all 3 stale targets point at symbols that still exist.
  • The sticky answer is arguably the better one; cold is the conservative one.
  • So this does not breach the zero-phantom guarantee.
  • But find_references / find_callers results depend on your edit history rather than on the tree, and a long-lived daemon drifts from what a fresh index would say. That is the same incoherence class that made I028 and I034 expensive.

Fix direction (NOT attempted — resolver changes need the full review process)

The narrow candidate: when any file is added/deleted/renamed in a batch, also NULL target_id for resolved refs with qualified = 1 before the Full pass, so anchored resolutions are re-derived from current evidence. Bounded set; a no-op on cold indexes.

Deliberately not implemented in the discovering session. The resolver is the crown jewel and this project's history is explicit that resolver fixes need adversarial review before shipping — I037's first fix was wrong and the motivating phantom survived it. This wants its own mission with the precision gate + corpus suite as the clamp.

Interim handling

The corpus rename suite enumerates these 3 exact ref sites as a documented known incoherence citing this issue — the same device as oracle.toml's name_fallback_ceiling. Anything new, anywhere, still fails the gate.

Acceptance

  • incremental and cold agree on the cs-dapper rename repro
  • no phantom introduced (precision gate stays at 0)
  • corpus rename suite passes with an EMPTY known-incoherence list
  • perf impact of the widened invalidation measured on a tier-3 repo (#41)
**Found by the new corpus cold==incremental suite (#42) on its first real run — the first defect the OSS corpus has produced.** Found at v0.8.7 @ 855710d. ## Symptom Rename `Dapper/SqlMapper.cs` → `Dapper/SqlMapper_renamed.cs` in the pinned `cs-dapper` corpus repo (a semantically meaningless change: C# does not tie type names to filenames). Then: | | `Dapper/CommandDefinition.cs:133:41` `CommandTimeout` (qualifier `SqlMapper.Settings`) | |---|---| | incremental re-index | `Dapper/SqlMapper.Settings.cs#CommandTimeout@76` | | cold index of the identical tree | `~UNRESOLVED~` | 3 refs affected in total (`CommandTimeout` ×2, `FetchSize` ×1), all in `CommandDefinition.cs`, all pointing at `SqlMapper.Settings.cs`. **The same working tree yields two different answers depending on edit history.** ## Repro ```bash cp -r ~/.cache/cosi-corpus/cs-dapper work && rm -rf work/.git code-index index --root work --db inc.db # cold mv work/Dapper/SqlMapper.cs work/Dapper/SqlMapper_renamed.cs code-index index --root work --db inc.db # incremental code-index index --root work --db cold.db # independent cold # diff the id-independent ref projection -> 3 rows differ ``` Or: `cargo test --release -p code-index-indexer --test corpus_metamorphic`. ## Root cause Two mechanisms combine. **1. Resolution is sticky.** Every resolve tier in `crates/indexer/src/index.rs` is gated on `refs.target_id IS NULL` (lines 765, 1170, 1454, 1587, 1605, 1683). An already-resolved ref is never re-evaluated. **2. Invalidation only tracks symbol NAMES, not evidence.** The sole un-resolver is the stale-name refill (`index.rs:367`): ```sql UPDATE refs SET target_id = NULL WHERE ... AND name IN (SELECT name FROM stale_outline_names) ``` `stale_outline_names` holds names whose *symbol outline* changed. But the I025 qualifier anchoring derives evidence from the **file path**: `temp.file_keys` is populated from `Path::file_stem` (`index.rs:879`), and `temp.qual_anchor_files` anchors a qualifier on a file whose key equals `q_first` (`index.rs:1274-1280`). So `Dapper/SqlMapper.cs` (stem `SqlMapper`) is what anchors the qualifier `SqlMapper.Settings`. Renaming that file destroys the anchoring evidence — but the *name* `CommandTimeout` never became stale, because its defining file (`SqlMapper.Settings.cs`) was untouched. So the refs keep their stale target. ## Why it only shows up here The trigger requires **anchor file ≠ target file**. Measured across all 7 tier-1 corpus repos, renaming the hottest file: **0 incoherent refs everywhere**. In Rust/Python/TS/JS/PHP/Ruby the anchor file usually *is* the target file (`flags::defs::X` anchors on `defs.rs`, where `X` also lives) — so renaming it re-mints `X`, its name goes stale, the refill fires, and coherence is preserved by accident. C# partial classes split `SqlMapper` across `SqlMapper.cs` and `SqlMapper.Settings.cs`, decoupling anchor from target and exposing the gap. Any language allowing that split is affected. ## Severity: medium — coherence, NOT a phantom - 0 dangling targets: all 3 stale targets point at symbols that still exist. - The sticky answer is arguably the *better* one; cold is the conservative one. - So this does **not** breach the zero-phantom guarantee. - But `find_references` / `find_callers` results depend on your edit history rather than on the tree, and a long-lived daemon drifts from what a fresh index would say. That is the same incoherence class that made I028 and I034 expensive. ## Fix direction (NOT attempted — resolver changes need the full review process) The narrow candidate: when any file is added/deleted/renamed in a batch, also NULL `target_id` for resolved refs with `qualified = 1` before the Full pass, so anchored resolutions are re-derived from current evidence. Bounded set; a no-op on cold indexes. Deliberately not implemented in the discovering session. The resolver is the crown jewel and this project's history is explicit that resolver fixes need adversarial review before shipping — **I037's first fix was wrong and the motivating phantom survived it**. This wants its own mission with the precision gate + corpus suite as the clamp. ## Interim handling The corpus rename suite enumerates these 3 exact ref sites as a documented known incoherence citing this issue — the same device as `oracle.toml`'s `name_fallback_ceiling`. Anything new, anywhere, still fails the gate. ## Acceptance - [ ] incremental and cold agree on the cs-dapper rename repro - [ ] no phantom introduced (precision gate stays at 0) - [ ] corpus rename suite passes with an EMPTY known-incoherence list - [ ] perf impact of the widened invalidation measured on a tier-3 repo (#41)
Author
Member

Correction — the issue as filed is wrong in two ways, and the defect is broader

Investigation + independent reproduction. Three claims in the original description do not survive.

1. It is NOT C#-specific

Filed as triggered by C# partial classes. It reproduces in all six languages with a 3-file hermetic fixture — no corpus needed. Verified in Rust directly:

src/foo.rs       // a comment, no symbols at all
src/bar_impl.rs  pub struct Bar;
                 impl Bar { pub fn make() -> u32 { 1 } }
src/user.rs      pub fn go() -> u32 { foo::Bar::make() }
baseline (cold)     : src/bar_impl.rs#make    qualifier=foo::Bar qualified=1
after rename (warm) : src/bar_impl.rs#make    <- sticky
after rename (cold) : ~UNRESOLVED~

The general shape is: a third file's stem supplies temp.qual_known root plausibility (index.rs:1252-1267), unlocking the parent-anchor arm (index.rs:1326-1336); the target lives in neither the ref's file nor the anchor file. C# partial classes are one way to get there, not the only one.

2. The anchor file needs NO SYMBOLS — so there is no Full pass

qual_known tests only for a file_keys row, and file_keys gets a stem row for every code file regardless of content (index.rs:879-890). Verified above: symbols in foo.rs = 0, and it still anchors.

This is worse than filed. A symbol-less file contributes nothing to stale_outline_names on delete (writer.rs:336-370), so renaming it takes the Scoped pass — or Skipped entirely. The original description assumed a Full pass was running and merely failing to invalidate enough. It often isn't running at all.

3. The proposed fix, applied naively, causes PERMANENT RECALL LOSS

The description proposed NULLing qualified = 1 resolutions when paths change. Simulated that firing outside a Full pass:

baseline                     : src/bar_impl.rs#make
after simulated invalidation : ~UNRESOLVED~
after unrelated body edit    : ~UNRESOLVED~     <- Scoped pass does not revisit it
after another no-op reindex  : ~UNRESOLVED~     <- pending tables empty, Skipped forever

The scope predicate (index.rs:664-666) only re-resolves refs whose file is in scope or whose name matches a symbol in a scope file. A NULLed ref outside that set is never revisited, and apply_resolution short-circuits to Skipped (index.rs:349-352). On a long-lived daemon this is monotonic, silent recall decay — strictly worse than the bug being fixed.

Hard constraint on any fix: invalidation may only ever fire in the same transaction as a guaranteed Full pass.

What IS confirmed

  • The narrow predicate qualified = 1 AND qualifier IS NOT NULL is correct — but for a different reason than I gave. I initially thought tier 1b's file_keys/file_pkg joins (index.rs:1025/1028/1035/1044) made it insufficient. They don't: those joins are keyed on the ref's file and the candidate's file only, and both are re-minted or name-refilled when they change, so they self-heal. Same for tier 1R (index.rs:1869/1886/1896, keyed on m.file_id/rb.file_id). Third-file evidence is confined to tier 1Q, whose own gate is exactly this predicate (index.rs:1170-1171, 1454-1455). Sizing on this repo: 862 of 7817 resolved refs; qualifier IS NOT NULL alone would over-invalidate 2.2×.
  • "NULL everything + Full pass" is byte-identical to a cold index. That makes invalidate-all a valid reference implementation any narrower fix must agree with.
  • A migration is required. Existing DBs carry stale resolutions the fix alone will never revisit (every tier is target_id IS NULL-gated). Spec 04's contract is explicit — see m0019_pool_hygiene_reheal.rs:4-8. Current schema is 22; this needs an m0023 resolve-only re-heal, and the heal must be the wide NULL-everything form, not the fix's own predicate, or the migration test becomes tautological.

Revised acceptance

  • invalidation fires ONLY with a guaranteed Full pass — a hermetic test proves an unrelated body edit never drops an anchored resolution
  • six-language matrix, each with a positive control proving the decoupled shape is present
  • refs_invalidated reported as a MEASURED count, not derived
  • no-op reconcile still reports Skipped with zero invalidation
  • m0023 re-heal converges an existing DB to cold
  • known_incoherence in corpus_metamorphic.rs empties out
  • precision gate and corpus mutation guard stay at 0 phantoms
## Correction — the issue as filed is wrong in two ways, and the defect is broader Investigation + independent reproduction. **Three claims in the original description do not survive.** ### 1. It is NOT C#-specific Filed as triggered by C# partial classes. It reproduces in **all six languages** with a 3-file hermetic fixture — no corpus needed. Verified in Rust directly: ``` src/foo.rs // a comment, no symbols at all src/bar_impl.rs pub struct Bar; impl Bar { pub fn make() -> u32 { 1 } } src/user.rs pub fn go() -> u32 { foo::Bar::make() } ``` ``` baseline (cold) : src/bar_impl.rs#make qualifier=foo::Bar qualified=1 after rename (warm) : src/bar_impl.rs#make <- sticky after rename (cold) : ~UNRESOLVED~ ``` The general shape is: a **third** file's stem supplies `temp.qual_known` root plausibility (`index.rs:1252-1267`), unlocking the parent-anchor arm (`index.rs:1326-1336`); the target lives in neither the ref's file nor the anchor file. C# partial classes are one way to get there, not the only one. ### 2. The anchor file needs NO SYMBOLS — so there is no Full pass `qual_known` tests only for a `file_keys` row, and `file_keys` gets a stem row for **every code file** regardless of content (`index.rs:879-890`). Verified above: `symbols in foo.rs = 0`, and it still anchors. This is worse than filed. A symbol-less file contributes nothing to `stale_outline_names` on delete (`writer.rs:336-370`), so renaming it takes the **Scoped** pass — or `Skipped` entirely. The original description assumed a Full pass was running and merely failing to invalidate enough. It often isn't running at all. ### 3. The proposed fix, applied naively, causes PERMANENT RECALL LOSS The description proposed NULLing `qualified = 1` resolutions when paths change. Simulated that firing outside a Full pass: ``` baseline : src/bar_impl.rs#make after simulated invalidation : ~UNRESOLVED~ after unrelated body edit : ~UNRESOLVED~ <- Scoped pass does not revisit it after another no-op reindex : ~UNRESOLVED~ <- pending tables empty, Skipped forever ``` The scope predicate (`index.rs:664-666`) only re-resolves refs whose file is in scope or whose *name* matches a symbol in a scope file. A NULLed ref outside that set is never revisited, and `apply_resolution` short-circuits to `Skipped` (`index.rs:349-352`). On a long-lived daemon this is **monotonic, silent recall decay** — strictly worse than the bug being fixed. **Hard constraint on any fix: invalidation may only ever fire in the same transaction as a guaranteed Full pass.** ## What IS confirmed - **The narrow predicate `qualified = 1 AND qualifier IS NOT NULL` is correct** — but for a different reason than I gave. I initially thought tier 1b's `file_keys`/`file_pkg` joins (`index.rs:1025/1028/1035/1044`) made it insufficient. They don't: those joins are keyed on the **ref's file and the candidate's file only**, and both are re-minted or name-refilled when they change, so they self-heal. Same for tier 1R (`index.rs:1869/1886/1896`, keyed on `m.file_id`/`rb.file_id`). **Third-file evidence is confined to tier 1Q**, whose own gate is exactly this predicate (`index.rs:1170-1171`, `1454-1455`). Sizing on this repo: 862 of 7817 resolved refs; `qualifier IS NOT NULL` alone would over-invalidate 2.2×. - **"NULL everything + Full pass" is byte-identical to a cold index.** That makes invalidate-all a valid reference implementation any narrower fix must agree with. - **A migration is required.** Existing DBs carry stale resolutions the fix alone will never revisit (every tier is `target_id IS NULL`-gated). Spec 04's contract is explicit — see `m0019_pool_hygiene_reheal.rs:4-8`. Current schema is 22; this needs an m0023 resolve-only re-heal, and the heal must be the **wide** NULL-everything form, not the fix's own predicate, or the migration test becomes tautological. ## Revised acceptance - [ ] invalidation fires ONLY with a guaranteed Full pass — a hermetic test proves an unrelated body edit never drops an anchored resolution - [ ] six-language matrix, each with a positive control proving the decoupled shape is present - [ ] `refs_invalidated` reported as a MEASURED count, not derived - [ ] no-op reconcile still reports `Skipped` with zero invalidation - [ ] m0023 re-heal converges an existing DB to cold - [ ] `known_incoherence` in `corpus_metamorphic.rs` empties out - [ ] precision gate and corpus mutation guard stay at 0 phantoms
Author
Member

Fixed

The change

A new persistent pending table stale_path_evidence records the tier-1Q anchor keys (file stem + package tail) a code file contributes, written whenever a path JOINS or LEAVES the code-file set. apply_resolution then, in the same IMMEDIATE transaction:

  1. tests whether any changed key could matter — instr(qualifier, key) > 0, a strict superset of every qual_known arm (each requires the key to appear literally inside the qualifier), so it cannot miss a case while needing no knowledge of the segmentation rules;
  2. if so, NULLs the matching qualified = 1 resolutions and forces ResolveScope::Full;
  3. clears the table alongside the other two pending tables.

Steps 2's two halves are deliberately inseparable. The UPDATE fixes the rename direction; forcing Full fixes the add direction (Scoped's predicate is file-local and structurally cannot apply a newly-available anchor to an untouched file). And forcing Full is what makes the UPDATE safe — NULLing under a Scoped pass loses the resolution permanently.

Migrations: m0024_anchor_path_evidence creates the table and runs the resolve-only re-heal (wide NULL-everything, deliberately not the fix's own predicate, so the migration asserts agreement with a cold index rather than with itself). It is ordered after m0023_symbols_parent_name_index because that index halves the re-heal's cost (46.7s → 25.3s on rust-analyzer).

Adversarial review — could not break the core fix

Verified by an independent reviewer that built repros rather than reasoning:

  • No sequence exists where invalidation fires without a Full pass in the same transaction. Tested via an injected error after the UPDATE (refs untouched, signal retained, retry converges), an 8-round two-process race over a 123-file tree, and full projections on rust-analyzer: after an add, 14,009 invalidated / 14,009 restored, 0 rows differ; after a rename, 12,661 invalidated / 29,918 resolved, 0 rows differ from cold.
  • qualified = 1 is the right predicate. Every other path-derived input (tier 1b same-dir/file-key/pkg arms, tier 3's reachability, tier 1R's origin gate) keys on the ref's own file or the candidate's own file, both of which self-heal via re-mint + the stale-name refill. No third-file path change flipping an unqualified resolution could be constructed.
  • Mutants that remove the UPDATE, remove the Full-forcing, fire on every upsert, or stop clearing the table are all killed by the shipped tests.

Six findings, all fixed

finding fix
F1 the write_delete arm had zero coverage — it could be deleted with the whole suite green (every test reached it via a rename, which also fires the upsert arm) plain_delete_of_a_symbol_less_anchor_matches_cold_index
F2 corpus_cold_equals_incremental_add was vacuous — it passed with the entire fix disabled, because its probe stem was chosen so "no source refers to it", which structurally cannot make a qualifier root-plausible rewritten to derive the stem by modelling the parent-anchor arm, then verifying by throwaway cold index that the projection actually moves; now executes on 3 repos (std, Handler, bob) and honestly skips 4
F3 code↔text reclassification at an unchanged path was unrecorded in both directions: write_stat_touch re-stamps kind with no guard, and write_upsert only fired on transitions into the code set both directions now call note_path_evidence; empty keys rejected (instr(x,'') is 1 in SQLite and would match every qualifier)
F4 1000× regression: deleting one symbol-less file went 17ms → 17.9s, holding the write lock against a 5s busy_timeout the instr relevance test above; an irrelevant path change now clears the signal and returns Skipped outright, verified by irrelevant_path_change_does_not_force_a_full_pass
F5 migration order made the upgrade pay the cost the index removes swapped; files renamed so version and filename agree
F6/F7 orphaned doc comment; write-only RunCounters.refs_invalidated doc restored; dead counter removed (IndexStats.refs_invalidated remains and is asserted on)

F4's fix produced a further improvement found only by writing its test: an irrelevant path change previously still ran a near-empty Scoped pass and left the signal populated. It now skips and clears.

Acceptance

  • invalidation fires ONLY with a guaranteed Full pass — unrelated_body_edit_never_loses_an_anchored_resolution runs 3 body edits asserting Scoped, refs_invalidated == 0 and target stability
  • cross-language matrix with per-language positive controls — rust, python, php; see the honest limitation below
  • refs_invalidated MEASURED from the UPDATE's row count, never derived
  • no-op reconcile still Skipped with zero invalidation
  • m0024 re-heal converges an existing v22 DB to cold, leaving the stat/hash tiers alone
  • known_incoherence is empty and the rename suite runs with tolerate_known: false
  • precision gate and corpus mutation guard stay at 0 phantoms

Honest limitation: the matrix is 3 languages, not 6

The earlier claim that this reproduces in all six was based on a table of file/ref names without source bodies. I could construct and verify the decoupled tier-1Q shape in rust, python and php only. Every TypeScript, Ruby and C# fixture I tried either captured no qualifier (qualified = 0) or resolved through a self-healing tier — so including them would have meant asserting on a shape that was not present. anchor_rename.rs::assert_shape fails loudly rather than silently passing if someone adds such a fixture.

The invalidation itself is one language-agnostic SQL predicate, so this is a gap in regression coverage for qualifier capture, not a gap in the fix. C#'s real instance — the one that found this bug — remains covered end-to-end by the corpus rename suite over pinned cs-dapper.

## Fixed ### The change A new persistent pending table `stale_path_evidence` records the tier-1Q anchor keys (file stem + package tail) a **code** file contributes, written whenever a path JOINS or LEAVES the code-file set. `apply_resolution` then, in the same IMMEDIATE transaction: 1. tests whether any changed key could matter — `instr(qualifier, key) > 0`, a **strict superset** of every `qual_known` arm (each requires the key to appear literally inside the qualifier), so it cannot miss a case while needing no knowledge of the segmentation rules; 2. if so, NULLs the matching `qualified = 1` resolutions **and forces `ResolveScope::Full`**; 3. clears the table alongside the other two pending tables. Steps 2's two halves are deliberately inseparable. The UPDATE fixes the rename direction; forcing Full fixes the add direction (`Scoped`'s predicate is file-local and structurally cannot apply a newly-available anchor to an untouched file). And forcing Full is what makes the UPDATE *safe* — NULLing under a Scoped pass loses the resolution permanently. Migrations: `m0024_anchor_path_evidence` creates the table and runs the resolve-only re-heal (wide NULL-everything, deliberately not the fix's own predicate, so the migration asserts agreement with a cold index rather than with itself). It is ordered **after** `m0023_symbols_parent_name_index` because that index halves the re-heal's cost (46.7s → 25.3s on rust-analyzer). ### Adversarial review — could not break the core fix Verified by an independent reviewer that built repros rather than reasoning: - **No sequence exists where invalidation fires without a Full pass in the same transaction.** Tested via an injected error *after* the UPDATE (refs untouched, signal retained, retry converges), an 8-round two-process race over a 123-file tree, and full projections on rust-analyzer: after an add, 14,009 invalidated / 14,009 restored, **0 rows differ**; after a rename, 12,661 invalidated / 29,918 resolved, **0 rows differ from cold**. - **`qualified = 1` is the right predicate.** Every other path-derived input (tier 1b same-dir/file-key/pkg arms, tier 3's reachability, tier 1R's origin gate) keys on the ref's own file or the candidate's own file, both of which self-heal via re-mint + the stale-name refill. No third-file path change flipping an unqualified resolution could be constructed. - Mutants that remove the UPDATE, remove the Full-forcing, fire on every upsert, or stop clearing the table are all killed by the shipped tests. ### Six findings, all fixed | | finding | fix | |---|---|---| | F1 | the `write_delete` arm had **zero** coverage — it could be deleted with the whole suite green (every test reached it via a rename, which also fires the upsert arm) | `plain_delete_of_a_symbol_less_anchor_matches_cold_index` | | F2 | **`corpus_cold_equals_incremental_add` was vacuous** — it passed with the entire fix disabled, because its probe stem was chosen so "no source refers to it", which structurally cannot make a qualifier root-plausible | rewritten to derive the stem by modelling the parent-anchor arm, then **verifying** by throwaway cold index that the projection actually moves; now executes on 3 repos (`std`, `Handler`, `bob`) and honestly skips 4 | | F3 | code↔text reclassification at an unchanged path was unrecorded in **both** directions: `write_stat_touch` re-stamps `kind` with no guard, and `write_upsert` only fired on transitions *into* the code set | both directions now call `note_path_evidence`; empty keys rejected (`instr(x,'')` is 1 in SQLite and would match every qualifier) | | F4 | **1000× regression**: deleting one symbol-less file went 17ms → 17.9s, holding the write lock against a 5s `busy_timeout` | the `instr` relevance test above; an irrelevant path change now clears the signal and returns `Skipped` outright, verified by `irrelevant_path_change_does_not_force_a_full_pass` | | F5 | migration order made the upgrade pay the cost the index removes | swapped; files renamed so version and filename agree | | F6/F7 | orphaned doc comment; write-only `RunCounters.refs_invalidated` | doc restored; dead counter removed (`IndexStats.refs_invalidated` remains and is asserted on) | F4's fix produced a further improvement found only by writing its test: an irrelevant path change previously still ran a near-empty `Scoped` pass and left the signal populated. It now skips and clears. ### Acceptance - [x] invalidation fires ONLY with a guaranteed Full pass — `unrelated_body_edit_never_loses_an_anchored_resolution` runs 3 body edits asserting `Scoped`, `refs_invalidated == 0` and target stability - [x] cross-language matrix with per-language positive controls — **rust, python, php**; see the honest limitation below - [x] `refs_invalidated` MEASURED from the UPDATE's row count, never derived - [x] no-op reconcile still `Skipped` with zero invalidation - [x] m0024 re-heal converges an existing v22 DB to cold, leaving the stat/hash tiers alone - [x] `known_incoherence` is **empty** and the rename suite runs with `tolerate_known: false` - [x] precision gate and corpus mutation guard stay at 0 phantoms ### Honest limitation: the matrix is 3 languages, not 6 The earlier claim that this reproduces in all six was based on a table of file/ref names without source bodies. I could construct and **verify** the decoupled tier-1Q shape in rust, python and php only. Every TypeScript, Ruby and C# fixture I tried either captured no qualifier (`qualified = 0`) or resolved through a self-healing tier — so including them would have meant asserting on a shape that was not present. `anchor_rename.rs::assert_shape` fails loudly rather than silently passing if someone adds such a fixture. The invalidation itself is one language-agnostic SQL predicate, so this is a gap in *regression coverage for qualifier capture*, not a gap in the fix. C#'s real instance — the one that found this bug — remains covered end-to-end by the corpus rename suite over pinned cs-dapper.
Author
Member

Fixed and released in v0.9.0 (schema v25, migration m0024_anchor_path_evidence).

Tier 1Q anchored qualified refs on file stems with no invalidation, so renaming an anchor file left resolutions a cold index would decline — a coherence defect, not a phantom one. Fixed by adding a third pending count (stale_path_evidence) in apply_resolution, in both the fast path and the in-transaction re-read, gated by an instr() relevance test so an irrelevant path change doesn't force a full re-resolve (the first cut regressed a single-file delete from 17ms to 17.9s).

Note for the record: the fix originally filed on this issue would have caused permanent recall loss. Reproducing the failure mode first is what caught that.

Regression cover: crates/indexer/tests/anchor_rename.rs (rust/python/php matrix, with a positive control asserting the anchor stays symbol-less) plus corpus_metamorphic::corpus_cold_equals_incremental_rename over the pinned cs-dapper repo, which is where this was originally found.

Fixed and released in **v0.9.0** (schema v25, migration `m0024_anchor_path_evidence`). Tier 1Q anchored qualified refs on file *stems* with no invalidation, so renaming an anchor file left resolutions a cold index would decline — a coherence defect, not a phantom one. Fixed by adding a third pending count (`stale_path_evidence`) in `apply_resolution`, in both the fast path and the in-transaction re-read, gated by an `instr()` relevance test so an irrelevant path change doesn't force a full re-resolve (the first cut regressed a single-file delete from 17ms to 17.9s). Note for the record: the fix originally filed on this issue would have caused **permanent recall loss**. Reproducing the failure mode first is what caught that. Regression cover: `crates/indexer/tests/anchor_rename.rs` (rust/python/php matrix, with a positive control asserting the anchor stays symbol-less) plus `corpus_metamorphic::corpus_cold_equals_incremental_rename` over the pinned cs-dapper repo, which is where this was originally found.
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#52
No description provided.