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
| Surface | Behaviour |
|---|---|
driftWarning on a search response | Once 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 prime | Every time. Prints none when the stamps match. |
get_index_status | Every 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.
| Axis | Compared | Stamp | Example |
|---|---|---|---|
| Payload keys | the payload signal descriptors the running build declares vs the keys the collection recorded when it was indexed | stats cache payloadFieldKeys | codegraph.symbols.chunk.fanIn: absent → declared |
| Language versions | per-language grammar / chunking / walker / codegraphSchema | registry languageVersions | python.walker: 1 → 3 |
* (shared, not an axis) | shared kernel / resolver chain / chunker versions | languageVersions["*"] | *.walker: 1 → 2 |
| Indexing env | the canonical indexing keys, grouped by what a change to each invalidates — chunk set, git enrichment, codegraph enrichment; runtime-only keys never drift | the registry env snapshot plus the dedicated identity fields | CODEGRAPH_AMBIGUOUS_RESOLVE_MODE: strict → first |
| Working tree | HEAD's sha vs the commit the last run indexed | RegistryGitState | main: 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.
An Indexing env finding means the next index run started from the process that
printed it would write something different. Where that process's environment
came from matters. A shell export in front of tea-rags prime or
tea-rags index-codebase is an override, so it is reported. The MCP server's
environment is a default shared by every project it serves, so it never
overrides a project's recorded index shape (see
Configuration Variables), and a server started
with another project's chunk size reports nothing for this one.
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 run with CODEGRAPH_ENABLED=false or TRAJECTORY_GIT_ENABLED=false
declares fewer keys than the index recorded, and every codegraph.* key reports
as removed. The env axis names the flag that explains it.
The opposite direction is not reported for codegraph: a project whose registry
records codegraphEnabled: false is not missing the codegraph.* keys a
default-on process declares, because its own next run would not write them.
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
| Remedy | Command | Cost | What it rebuilds |
|---|---|---|---|
none | No 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. |
incremental | tea-rags index-codebase --project <alias> | seconds | The files that changed since the stamped commit. |
recompute | tea-rags index-codebase --project <alias> --force-enrichments <scope> [--languages <lang>] | minutes | The enrichment payload, in place. No re-embedding, chunk ids unchanged. |
force (scoped) | tea-rags index-codebase --project <alias> --force --test-file only [--languages <lang>] | proportional to the selection | The selected files only, in place: their chunks re-chunked and re-embedded, their enrichment rerun. Every other point untouched. |
force | tea-rags index-codebase --project <alias> --force | minutes to hours on a large project | Everything: 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.
Scoped chunk-set bumps
A chunking or grammar bump can declare which files it changed — for example, a
revision that only touched how test files are chunked declares
{ testFile: "only" }. When every pending chunk-set bump is scoped, the report
names a scoped force instead of the full rebuild:
Language versions:
ruby.chunking: 1 → 2
Run: tea-rags index-codebase --project myapp --force --test-file only --languages ruby
Several pending scoped bumps collapse into one command whose filter covers all
of them — languages are unioned, and a filter dimension the bumps disagree on is
dropped, so the command re-chunks a superset rather than too few files. One
unscoped bump anywhere makes the whole report the plain --force.
A scoped force only rebuilds its selection, so it cannot stand in for a whole-collection enrichment recompute. When the report also carries one, it names it as a second step — the one case a report prints two lines:
Run: tea-rags index-codebase --project myapp --force --test-file only --languages ruby
Then: tea-rags index-codebase --project myapp --force-enrichments codegraph --languages typescript
A finished scoped run advances the stamp only for the bumps its selection fully covered; see Scoped force.
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
Which model a project uses
A registered project is embedded with the model it was indexed with, not with
the server's EMBEDDING_MODEL. Every index run records the project's embedding
provider, model, endpoints (EMBEDDING_BASE_URL, EMBEDDING_FALLBACK_URL) and
tuning in the registry (~/.tea-rags/registry.json). Search, index_codebase
and the auto-update run all build the project's provider from that entry. The
MCP server's environment only supplies defaults: for collections that have no
registry entry, and for keys the entry does not record.
So one MCP server serves projects indexed with different models. With
EMBEDDING_MODEL=nomic-ai/CodeRankEmbed in the server config, a project indexed
with brokkai/Muninn-small on :8091–8094 is still queried with Muninn-small on
:8091–8094. When the entry records a different model or provider than the
server's environment, the server's endpoints are ignored for that project too:
they serve the wrong model. When the model is the same, the server's endpoints
win, so moving a model to another host does not require a reindex.
A model switch is a deliberate rebuild. Pass the new model in the environment of the CLI run that rebuilds the project — an explicit command-line environment wins over the registry — and the run records it:
EMBEDDING_MODEL=nomic-ai/CodeRankEmbed EMBEDDING_BASE_URL=http://gpu-host:8081,8082 \
tea-rags index-codebase --project <alias> --force
The guard
EmbeddingModelGuard refuses a collection whose vectors came from a different
model than the one about to embed for it — for a registered project, the model
in its registry entry. 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. If it disagrees with the model that would embed for the
collection — the registry entry was edited by hand, or an unregistered
collection is opened with another EMBEDDING_MODEL — 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:
- Embed with the model that built the index — fix the registry entry (or, for
an unregistered collection,
EMBEDDING_MODEL); pin a version tag instead of:latestif 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-enrichmentscannot 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).
isHubandtransitiveImpactare not themselves triggers. Both are whole-collection quantities —isHubcompares against a p95 that moves for every file at once,transitiveImpactis a depth-capped reverse BFS — so they are refreshed for the files the diff already names and otherwise wait for the nexttea-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 chunking version, so every index built before it
reports *.chunking: 2 → 4 once. The bump is unscoped: top-level module code
is now indexed for every code file, .tsx files are parsed with the tsx
grammar, small Markdown sections are grouped instead of standing alone or being
dropped, and test files store their setup once per scope and pack small
examples (see How Chunking Works).
Each of these moves the chunk set, so the remedy is the plain force:
tea-rags index-codebase --project <alias> --force
An index with codegraph enabled may also report ruby.walker: 5 → 6: Ruby
class variables, ||= memoization and accessor macros (attr_reader and
friends) now record the declarations the naming tools read. The
force rebuilds the enrichment layer too, so the one command settles both. Until
you run it, search keeps working on the old chunks: test examples come back
with their setup inlined as before, and module-level code stays unindexed.
After that run the stamp catches up and the report goes quiet.
Related
- Recovery & Reindexing — the reindex modes and what each costs
- Auto-Update Watcher — who runs the incremental for you
- Troubleshooting & Error Codes — what to do when a run fails rather than merely drifts