Skip to main content

Drift Detection

What drift is

Every index run stamps what produced it: the payload keys the build declared, the version of each language's chunker and walker that ran, the indexing environment, the commit HEAD sat on. Drift is the comparison of those stamps against what the current build, environment or working tree would produce now.

Nothing breaks when they disagree. The index keeps answering — some of what it answers with was produced by an older build. The report tells you which stamps moved and names the one command that repairs all of them.

Where you see it

SurfaceBehaviour
driftWarning on a search responseOnce per collection per distinct report per server session, and again after every index run. A clean check spends nothing, and a report that gains a finding warns again. It rides along with an answer, so it does not nag.
## Drift in tea-rags primeEvery time. Prints none when the stamps match.
get_index_statusEvery time, appended as a ## Drift block when something moved.

prime and get_index_status inspect without consuming: a status call has to answer the same way twice, and the once-per-report warning belongs to the next search.

Axes

One monitor per axis, and a single report can carry findings from several. The * row below is the exception: it is not an axis of its own but a subject inside Language versions, so its findings render under that heading.

AxisComparedStampExample
Payload keysthe payload signal descriptors the running build declares vs the keys the collection recorded when it was indexedstats cache payloadFieldKeyscodegraph.symbols.chunk.fanIn: absent → declared
Language versionsper-language grammar / chunking / walker / codegraphSchemaregistry languageVersionspython.walker: 1 → 3
* (shared, not an axis)shared kernel / resolver chain / chunker versionslanguageVersions["*"]*.walker: 1 → 2
Indexing envthe canonical indexing keys, grouped by what a change to each invalidates — chunk set, git enrichment, codegraph enrichment; runtime-only keys never driftthe registry env snapshot plus the dedicated identity fieldsCODEGRAPH_AMBIGUOUS_RESOLVE_MODE: strict → first
Working treeHEAD's sha vs the commit the last run indexedRegistryGitStatemain: abcdef1 (dirty) → 0123456

A moved HEAD is the cheapest finding there is: the remedy is a plain incremental run, which the auto-update watcher performs on its own when it is enabled, so the finding usually clears without anyone typing anything. The subject is the branch the index was built on; when HEAD now sits on a different branch, current carries the new one — 0123456 (feature), with the note HEAD moved to feature since the last index run and the same (dirty) suffix rule on the indexed side.

Reading a report

A finding is one line — subject: indexed → current — grouped under its axis, with the single command last:

Language versions:
python.walker: 1 → 3
Run: tea-rags index-codebase --project myapp --force-enrichments codegraph --languages python

A finding may carry a note in parentheses. A note explains what the reading means, rather than restating it:

Indexing env:
TRAJECTORY_GIT_ENABLED: true → false (explains any git.* payload-key drift — restore the flag instead of rebuilding)

The command is folded over every finding, so a report with several lines still names exactly one. Findings that belong to a single language keep --languages; one collection-wide finding — the shared * sources, an env key — widens the whole command:

Language versions:
python.walker: 1 → 3
*.walker: 1 → 2
Run: tea-rags index-codebase --project myapp --force-enrichments codegraph

Phantom schema drift

The most common payload-key drift is not a schema change at all. Codegraph and git payload descriptors are declared only when their trajectory is enabled, so a process without CODEGRAPH_ENABLED or TRAJECTORY_GIT_ENABLED declares fewer keys than the index recorded, and every codegraph.* key reports as removed. The env axis names the flag that explains it, and reports it whether the reading process set the flag to false or simply never had it.

The fix is to restore the flag in the process that reads the index — a SessionStart hook running in a fresh shell is the standing offender — not to rebuild anything. That is why the finding carries no command: on its own it renders No action required., which is literally true. Set the flag and the env row and the payload-key report clear together.

Because the row describes the reading process, each surface answers for itself, and prime is a special case: the CODEGRAPH_ENABLED row never appears in prime's ## Drift, so its absence there tells you nothing about the server. The reason is the same re-apply that keeps prime honest elsewhere — prime takes CODEGRAPH_ENABLED from the registry before it builds its composition, which is what stops it reporting phantom removed keys. Read that flag where it matters, from the MCP server: get_index_status, or the driftWarning on a search response. TRAJECTORY_GIT_ENABLED gets no such re-apply and shows up wherever it is flipped, prime included — the note example under Reading a report is exactly that line.

Remedies and their cost

RemedyCommandCostWhat it rebuilds
noneNo action required.Nothing — and for two reasons. Either the finding is something the current build stopped declaring, and nothing reads a key that is not declared; or it is an enable-flag flip, whose fix is restoring the flag in the process that reads the index, which no rebuild performs.
incrementaltea-rags index-codebase --project <alias>secondsThe files that changed since the stamped commit.
recomputetea-rags index-codebase --project <alias> --force-enrichments <scope> [--languages <lang>]minutesThe enrichment payload, in place. No re-embedding, chunk ids unchanged.
forcetea-rags index-codebase --project <alias> --forceminutes to hours on a large projectEverything: new chunk ids, every vector re-embedded. Zero downtime — built into a new collection, alias swaps at the end.

The report always names the cheapest command that repairs every finding it lists, with --project already filled in from the registry. Escalation is one-directional: a report mixing an enrichment-owned finding with a chunk-set one gives you the --force, which rebuilds the enrichment layer anyway.

--force is reserved for findings that MOVE THE CHUNK SET — a grammar or chunking version, a changed embedding model or chunk size. Chunk point ids hash file content and line range, so those relocate every id and nothing short of a full rebuild is coherent. Everything else rewrites payload in place, which is why --force-enrichments is measured in minutes where --force is measured in hours.

A report can also be non-empty and say No action required. — that is a collection carrying payload keys this build no longer declares, which costs nothing and repairs itself the next time those points are written.

Embedding model

EmbeddingModelGuard refuses a collection built by a different embedding model than the one currently configured. It checks two things, and the second is why an index that "nothing changed" under can still be wrong.

The name. Every collection's marker point (__indexing_metadata__) stores embeddingModel. Point EMBEDDING_MODEL somewhere else and the next search or index run fails with HTTP 409:

Embedding model mismatch: collection indexed with "nomic-embed-text",
current config uses "mxbai-embed-large"

The weights. A name is not an identity. nomic-embed-text:latest is a moving target: the tag gets republished upstream, ollama pull fetches new weights, and your collection keeps the same model NAME while its stored vectors came from a model that no longer exists. Search still returns results — they are quietly worse, because query vectors and stored vectors now sit in slightly different spaces.

So the guard also stores a canary: one fixed text, embedded the first time it sees the collection and kept in the marker next to the model name. Every later run re-embeds that text and compares it against the stored vector by cosine. Below EMBEDDING_CANARY_MIN_COSINE (0.999) the collection is refused with the same 409, and the reason says which check fired:

Embedding model mismatch: collection indexed with "nomic-embed-text",
current config uses "nomic-embed-text (same name, different weights:
canary cosine 0.9412)"

Two ways out:

  • Point EMBEDDING_MODEL back at the model that built the index — pin a version tag instead of :latest if one exists.
  • Rebuild with the model you now have: tea-rags index-codebase --project <alias> --force. A full reindex is the only tool for this; the vectors themselves are what changed, so --force-enrichments cannot help.

Collections indexed before this check existed have no canary. The first run that opens one embeds the canary and writes it into the marker, so comparison starts from that moment — it does not retroactively detect a swap that already happened. A full reindex rewrites the marker without a canary, and the next run writes one from the model that did the rebuild.

If the embedding provider is unreachable when the canary would be checked, the guard logs [ModelGuard] Canary check skipped for <collection> and continues on the name comparison alone. A provider that is down must not block indexing.

Codegraph payload heal

A codegraph signal is a property of the whole graph, not of one file. When a file gains a caller, loses an importer, or drops in PageRank, the cause is usually a change somewhere else — and until now the payload only got rewritten for files that changed themselves. A file that stopped changing kept whatever fanIn, fanOut and pageRank it had the last time someone touched it.

Every index run now diffs the fresh graph against the signals it recorded at the end of the previous run and rewrites the payload of exactly the symbols and files that moved — including the ones no commit went near. It is a payload rewrite and nothing more: no re-extraction, no re-embedding, no change to the chunk set.

Two things to expect after upgrading:

  • The first run heals every point once. The baseline starts empty, so the first run treats everything as moved and does one full payload sweep — a single streaming pass over the collection in pages of 1000 points with one write batch per page — so it costs that one pass plus the writes for the points whose signals moved, and no embeddings. On the tea-rags self-index that first sweep named 7,465 points across 1,032 files. Runs after it are bounded by what actually changed, which on a normal incremental is a handful of files — and a small diff skips the pass altogether, reading just those files with one exact scroll each (a couple of milliseconds apiece).
  • isHub and transitiveImpact are not themselves triggers. Both are whole-collection quantities — isHub compares against a p95 that moves for every file at once, transitiveImpact is a depth-capped reverse BFS — so they are refreshed for the files the diff already names and otherwise wait for the next tea-rags index-codebase --force-enrichments codegraph.

The heal runs in the completion tail and reports itself in the debug log:

[+ 142.317s] [GitEnrich] PHASE: CODEGRAPH_PAYLOAD_HEAL | {"collection":"…","pointsRewritten":412,"filesTouched":57}

In the steady state that line stays, with zeros: a run whose graph diff is empty logs pointsRewritten: 0, filesTouched: 0 and costs 98 ms on the self-index. An incremental that found nothing to index at all is the one case with no line to look for — it runs no enrichment, so there is no graph to diff and nothing to heal. Absent line and zero line mean the same thing here.

A heal that fails is logged as CODEGRAPH_PAYLOAD_HEAL_FAILED and does not fail the run — the baseline is only advanced once the rewrite lands, so the next run retries the same diff.

After upgrading tea-rags

A report can appear right after an upgrade with nothing changed on your side. A release that touches a walker, a resolver chain or the shared kernel bumps the version the build declares, so every index built before it now carries an older stamp: the code moved, the payload did not.

This release bumps the shared walker, so every index built before it reports *.walker: 1 → 2 once. The shared sources run under every language, so the recompute it recommends is deliberately not narrowed by --languages:

tea-rags index-codebase --force-enrichments codegraph

After that run the stamp catches up and the report goes quiet.