# Codevira v2.1.3 — ChromaDB Write-Path Correctness

**Tracking**: [Issue #11](https://github.com/sachinshelke/codevira/issues/11)
**Status**: planning
**Target ship**: 2-3 days

---

## Context

v2.1.2 shipped with three classes of trust-recovery fixes (semantic threshold,
graceful degradation, hardening gates). Within hours of publish, a real user
session surfaced a deeper class of bug: **codevira's incremental indexer
re-submits the same code chunks to ChromaDB ~8× more than necessary**, causing
slow HNSW corruption that eventually consumes 60+ GB of disk per project.

The bug exists in every version since chunk-based indexing landed (v2.0+).
v2.1.2 didn't introduce it; v2.1.2's hardening gates didn't catch it because
they snapshot-test correctness, not long-running write amplification.

**Real-world impact (measured 2026-05-19 on a single user's machine):**

| Project | Unique embeddings | Total chroma writes | Ratio |
|---|---|---|---|
| AgentStore | 7,379 | 43,416 | **5.9×** |
| lh-interface | 1,079 | 9,660 | **9.0×** (codeindex hit 64 GB → disk full) |
| QuickCourier | 5,327 | 11,522 | 2.2× |
| UDAP | 6,977 | 10,442 | 1.5× |
| ToolsConnector | 7,734 | 10,047 | 1.3× |

This is the single largest credibility risk to the product: a "lightweight
memory layer" consuming 64 GB silently. Every active user is on this trajectory.

---

## Root cause — 3 bugs in `indexer/index_codebase.py`

### Bug 1 — `doc_id` uses unstable `start_line` (line 483)

```python
doc_id = f"{chunk.file_path}::{chunk.chunk_type}::{chunk.name}::{chunk.start_line}"
```

Insert one line at the top of a file → every chunk below it shifts by 1 →
every `doc_id` below it changes → ChromaDB sees them as "new vectors" even
though no chunk content changed. This is the primary amplifier.

### Bug 2 — `.add()` not `.upsert()` (line 905, line 689)

```python
collection.add(ids=ids, documents=docs, metadatas=metas)
```

`.add()` with existing IDs forces an HNSW graph reorganization every time.
`.upsert()` checks SQL metadata first and skips the HNSW write if the embedding
is identical. Defense in depth even after Bug 1 is fixed.

### Bug 3 — Full delete-then-add on any file hash change (lines 875–905)

Edit 1 character in a 200-chunk file → we delete 200 + re-add 200. Should be:
delete only chunks whose content hash actually changed.

---

## Item 1 — Content-addressed chunk IDs (the root fix)

**Problem.** `doc_id` includes `start_line`, which makes the ID unstable
under any insertion above the chunk.

**Design.**

```python
# indexer/index_codebase.py:482
def _chunk_to_document(chunk) -> tuple[str, str, dict]:
    import hashlib
    content_hash = hashlib.sha256(
        (chunk.source_text or "").encode("utf-8")
    ).hexdigest()[:16]
    doc_id = (
        f"{chunk.file_path}::{chunk.chunk_type}::{chunk.name}::{content_hash}"
    )
    ...
```

Properties:
- A chunk with identical content gets the SAME ID forever, regardless of
  where in the file it sits.
- `file_path + chunk_type + name` keeps IDs human-readable for debugging.
- Truncating the hash to 16 hex chars (64 bits) keeps the ID short while
  the collision probability is ≈ 1 in 2^32 per chunk per file → negligible
  for any realistic codebase.

**Migration**: existing collections are populated with line-based IDs. They
won't match the new IDs and will be invisible to upsert/dedup. v2.1.3 needs
to detect "this collection was populated by v2.1.2 or earlier" and force a
one-time `reset --vectors + index --full` on first use. (See Item 5.)

**Files**: `indexer/index_codebase.py:482-495` (only the `doc_id` line changes).

**Test**: `tests/test_index_codebase.py` — assert that the same chunk content
at different `start_line` produces the same `doc_id`; and that the same chunk
moved to a different line still upserts (not double-adds).

---

## Item 2 — `.upsert()` everywhere (defense in depth)

**Problem.** Two call sites still use `.add()`.

**Design.** Mechanical replacement at:
- `indexer/index_codebase.py:689` (full-rebuild batch)
- `indexer/index_codebase.py:905` (incremental per-file)

`.upsert()` is the chromadb-recommended call for "add if new, replace if
exists" semantics. With content-addressed IDs from Item 1, re-running on
identical chunks becomes a true no-op in chromadb's SQL layer (zero HNSW
writes).

**Test**: `tests/test_index_codebase.py` — run incremental index twice on
the same project, assert `max_seq_id` after run 2 == `max_seq_id` after
run 1. Currently this assertion would fail by 8× the chunk count.

---

## Item 3 — Per-chunk delta writes (not per-file wholesale)

**Problem.** When a file hash changes, lines 875–905 delete ALL chunks
for that file and re-add ALL chunks. Even chunks whose content didn't
change get re-submitted.

**Design.** Compute the new chunks' content hashes, fetch the existing
chunks' content hashes from chromadb (cheap — metadata-only query),
delete only chunks whose `(content_hash, chunk_name)` pair disappeared,
upsert only the additions.

```python
new_chunks_by_id = {_doc_id(c): c for c in new_chunks}
existing = collection.get(
    where={"file_path": fpath},
    include=["metadatas"],
)
existing_ids = set(existing["ids"])
new_ids = set(new_chunks_by_id.keys())

to_delete = list(existing_ids - new_ids)
to_upsert = [new_chunks_by_id[i] for i in new_ids - existing_ids]
# IDs in (existing_ids & new_ids) are unchanged → SKIP entirely

if to_delete:
    collection.delete(ids=to_delete)
if to_upsert:
    ids, docs, metas = ...
    collection.upsert(ids=ids, documents=docs, metadatas=metas)
```

After Item 1, the steady-state for a tiny edit is: 1–2 chunks deleted, 1–2
chunks upserted. Was: 200 deletes + 200 adds.

**Files**: `indexer/index_codebase.py:875-911` (the incremental update path).

**Test**: `tests/test_index_codebase.py` — edit a single chunk inside a
200-chunk file; assert chromadb sees exactly 1 delete + 1 upsert, not 200.

---

## Item 4 — One-shot migration on v2.1.2 → v2.1.3 upgrade

**Problem.** Existing collections were populated with line-based IDs. After
Item 1 ships, those IDs won't match anything the new code produces. Without
migration, every existing project would silently double-write everything
until the user noticed.

**Design.** On first MCP server start under v2.1.3, check a marker:

```python
# In indexer/index_codebase.py at chroma client init
MIGRATION_MARKER = "v2.1.3_content_addressed_ids"
collection_meta = collection.metadata or {}
if collection_meta.get("schema") != MIGRATION_MARKER:
    # Old collection — needs rebuild
    logger.warning(
        "codeindex schema is pre-v2.1.3 (line-based IDs). Rebuilding..."
    )
    # Auto-trigger reset + reindex; preserve decisions collection.
    _migrate_codeindex_to_v213(collection)
    collection.modify(metadata={"schema": MIGRATION_MARKER, ...})
```

The migration is roughly:
1. Drop the `codebase_index` collection (NOT `codevira_decisions`).
2. Recreate it with `metadata={"schema": "v2.1.3_..."}`.
3. Mark `file_hashes` table as stale so next incremental run does a full
   index with new content-addressed IDs.
4. Log a one-line user-visible message: "codeindex auto-migrated to v2.1.3
   schema (this happens once per project after upgrade)."

**Files**: `indexer/index_codebase.py` — new `_migrate_codeindex_to_v213()`
helper called from the chroma-client lazy init.

**Test**: spin up a v2.1.2-shaped collection (line-based IDs), run codevira
under v2.1.3, assert migration ran exactly once and second run is a no-op.

**Alternative considered**: in-place rewrite of old IDs to new IDs. Rejected
because (a) we'd need to re-read every chunk's source to compute the new
hash, which is the same cost as a full reindex anyway, and (b) the existing
HNSW link graph is already compromised — rebuilding gives us a clean graph
as a side effect.

---

## Item 5 — Long-running observability test

**Problem.** None of the 8 v2.1.2 gates (G1–G4 + G1.5/G1.6/G1.7 + G2.5) can
catch slow write amplification. They snapshot-test correctness; this bug
needs a "replay 100 edits, assert chroma writes stay proportional to unique
content" test.

**Design.** New `tests/integration/test_write_amplification.py`:

```python
def test_repeated_edits_do_not_amplify_writes(tmp_project):
    """v2.1.3 issue #11: edit the same file 50 times; assert chroma's
    max_seq_id grows by ~50 (one per edit), NOT by 50 * chunks_per_file.
    """
    create_file_with_n_chunks(tmp_project, "src/foo.py", n_chunks=20)
    index_full(tmp_project)
    initial_seq = chroma_max_seq_id(tmp_project, "codebase_index")

    for i in range(50):
        edit_file_in_place(tmp_project / "src/foo.py", change_one_chunk=True)
        index_incremental(tmp_project)

    final_seq = chroma_max_seq_id(tmp_project, "codebase_index")
    writes_per_edit = (final_seq - initial_seq) / 50
    assert writes_per_edit < 3.0, (
        f"Expected ~1 chroma write per edit; got {writes_per_edit:.1f}. "
        f"Write amplification regression — issue #11."
    )
```

Becomes G1.8 in `make release-gauntlet`.

**Files**:
- NEW `tests/integration/test_write_amplification.py`
- `Makefile::release-gauntlet` — add G1.8 step + evidence field.

**Test**: this test itself acts as the lock-in.

---

## Item 6 — Doctor check + insights warning

**Problem.** Users have no way to see they're trending toward catastrophe
until disk hits 100%.

**Design.**

Add to `codevira doctor`:

```
codeindex_write_ratio   chroma writes / unique embeddings per collection
  pass   < 1.5
  warn   1.5 - 3.0
  fail   > 3.0  (recommend: codevira reset --vectors)
```

Also surface in `codevira insights` ("3 projects above 2× write ratio —
codevira reset --vectors will free X GB").

**Files**:
- `mcp_server/doctor.py` — new `_check_codeindex_write_ratio()`.
- `mcp_server/cli_insights.py` — include the metric in the digest.

**Test**: `tests/test_doctor.py` — assert each verdict tier fires correctly
against a synthetic chroma db with known seq_id / unique count ratios.

---

## Implementation order

1. **Item 1** (content-addressed IDs) — the root fix, smallest diff.
2. **Item 2** (`.upsert()`) — 1-line, lock in immediately.
3. **Item 3** (per-chunk deltas) — biggest perf win after Items 1+2.
4. **Item 4** (migration) — without this, no one benefits from 1+2+3.
5. **Item 5** (write-amplification test) — regression lock-in.
6. **Item 6** (doctor + insights) — user-facing visibility.

Items 1+2 alone reduce the write ratio from 8× to ≈ 1× on a freshly-reset
project. Item 3 keeps it there even on heavy editing. Items 4-6 are
necessary supporting infrastructure but don't change steady-state behavior.

---

## Verification

### Per-item smoke tests

| Item | Test |
|---|---|
| #1 stable IDs | Same chunk content at different line → same doc_id. Re-import same project twice → `max_seq_id` unchanged on 2nd run. |
| #2 upsert | Two `.upsert()` calls with identical (id, content) → chromadb's metadata layer shows 0 net writes. |
| #3 delta writes | Edit 1 chunk in a 200-chunk file → assert exactly 1 delete + 1 upsert. |
| #4 migration | Spin up v2.1.2 chroma db, start v2.1.3 → 1st start triggers migration with log line; 2nd start is no-op. |
| #5 amplification test | `make test-integration` includes the new test; replay 50 edits, ratio ≤ 1.5×. |
| #6 doctor + insights | `codevira doctor` on the user's machine after upgrade — all 5 projects show "pass" once Items 1-4 have produced a clean reindex. |

### Release gauntlet additions

```
make release-gauntlet  →  adds G1.8 (write amplification)
.release-evidence/2.1.3.json adds: "G1_8_write_amplification": true
```

### End-to-end proof on Sachin's machine

After v2.1.3 ships + auto-migration completes:

```bash
# Re-run the blast-radius script from issue #11
python3 ~/bin/codevira-vector-health
```

Expected: all 5 projects show ratio ≤ 1.3× (was 1.3× – 9.0×).

---

## Explicitly out of scope

- **Replacing chromadb entirely** (faiss / hnswlib-direct / qdrant) — too
  invasive for a patch release. Track separately as v2.2 design pass.
- **Sparse-file-aware disk reporting** — `codevira projects` table size
  column already shows physical disk usage correctly. The 165 GB "logical"
  size confusion is a `stat`/`ls` quirk, not a codevira reporting bug.
- **Per-project disk budget / circuit breaker** — was proposed as a v2.1.3
  item earlier but pivoted to root-cause fix instead. Once Items 1-4 ship,
  the runaway-growth scenario is structurally impossible; a budget becomes
  belt-and-suspenders, not the primary defense.

---

## Documentation discipline (per v2.1.2 convention)

| Item | Docs to update |
|---|---|
| #1–3 (the 3 bug fixes) | CHANGELOG `[2.1.3]` section; brief mention in README "Architecture" |
| #4 (migration) | MIGRATING.md — new section "v2.1.2 → v2.1.3: codeindex auto-rebuilds once" |
| #5 (test) | CHANGELOG only |
| #6 (doctor) | CHANGELOG; README's `codevira doctor` example output |

---

## Hand-off checklist

- [ ] Items 1–6 implemented + tested
- [ ] CHANGELOG updated
- [ ] MIGRATING.md updated
- [ ] `make release-gauntlet` passes (8 gates → 9 gates with G1.8)
- [ ] Wheel built + smoke-installed
- [ ] G5 verified on a real machine (run on lh-interface that was reset; verify
      ratio stays close to 1× over 1 day of editing)
- [ ] `gh issue close 11` after publish + smoke
