Low-Level Design
The previous chapter is the high-level design: crates, subsystems, and how they fit together, in plain language. This one is the opposite: it is the implementation contract. Exact byte layouts, exact algorithm parameters, exact failure modes, and the invariants a change must not break. Written for someone about to open a pull request.
how to read this Every constant, formula, and threshold below was read from the Rust source and is cited with its file. Where a value is tunable at runtime the environment variable is given. Where a decision has an RFC or ADR behind it, that document is named, because changing the value without updating the document is treated as a defect in this codebase.
Contents
1 · Design invariants
These are the load-bearing rules. Everything else in this document is a consequence of one of them. A change that breaks one of these is an architectural change, not a bug fix, and needs an RFC.
No language model participates in determining a node or an edge. Tree-sitter and SCIP/LSIF produce the graph; PageRank, k-core, PCST and knapsack rank and pack it. The only neural component in the read path is a cross-encoder re-ranker, which reorders candidates that the deterministic pipeline already produced, and which is bypassed entirely if its model is absent.
Where parsing code runs (in-process vs sandboxed subprocess) is a trust and performance decision, decided per plugin. It never changes which edges are produced. This is what allows Tree-sitter to stay on the hot path with zero IPC while every untrusted semantic tool is isolated. (RFC-011)
A NodeId is a pure function of its VName and a format version
byte. The same symbol in the same repo always hashes to the same id, on any machine, in any
order. Changing the hash inputs is a global invalidation event. (RFC-002)
The same query against the same graph returns the same rows in the same order.
Every sort that could tie is explicitly broken by NodeId ascending,
in PageRank top-k, in RRF fusion, everywhere. Without this, HashMap
iteration order leaks into results and benchmarks become unreproducible.
Every optional subsystem fails open and visibly. If embeddings are cold, the
response header says embeddings: warming. If the KNN circuit breaker fired, it
says degraded. If nothing grounded the query, the tool abstains and returns its
term-resolution map rather than a plausible-looking answer. Silence is never allowed to look
like success.
A repo being indexed may be hostile. It must never be able to escalate: not through a build script a semantic indexer would run, not through a crafted length prefix on the plugin wire, not through a filename, and not through content that reaches an LLM's context. Every boundary in section 5 and section 7 exists for this reason.
System map
Five tiers. Dependencies flow strictly downward: no tier calls upward, and no cycles exist in the crate graph.
Solid arrows are compile-time crate dependencies; the dashed arrow is a
runtime relationship. travsr-plugin-host is outlined in red because it is the
only component that executes code originating outside the repository's own binary.
2 · Identity & the data model
Four types carry the whole system. They live in
travsr-core, the only crate every other crate depends on.
VName: the address
A Kythe-style five-tuple. It is the universal address space: stable across repositories, languages, and time.
struct VName {
corpus: String, // "github.com/acme/foo" (canonicalised, see below)
root: String, // build root / branch (usually "")
path: String, // "src/payment.ts" (repo-relative, always)
language: String, // "typescript"
signature: String, // "method:PaymentService.charge"
}
Corpus canonicalisation (ARCH-102) reduces every git remote URL form to
host/org/repo: lowercase, no scheme, no .git, no trailing slash.
https://github.com/acme/foo.git, git@github.com:acme/foo.git and
ssh://git@github.com/acme/foo all become github.com/acme/foo.
A repo with no remote gets local/<basename>, and cross-repo
exports edges are impossible for it by definition.
NodeId: the hash
The SQLite primary key. BLAKE3 over a deliberately unambiguous byte stream, truncated to 64 bits.
SIGNATURE_FORMAT_VERSION, a single byte, currently 2len₃₂ = the field's byte length as a 4-byte little-endian integer
c, r, p, l, s = corpus, root, path, language, signature, as UTF-8 bytes
Digest truncated to its first 8 bytes, read as little-endian
u64
Why length-prefix every field?
The original scheme joined fields with NUL separators. Two different VNames could then produce the same byte stream if a field itself contained the separator, a collision an attacker could construct. Length-prefixing makes the encoding injective: no two distinct VNames share a stream, regardless of contents.
Why the version byte first?
It is a domain separator. A v0 stream begins with raw corpus bytes; a v2 stream begins
with 0x02 followed by a length. The two id spaces can never overlap, so a
stale database can be detected rather than silently mixed. Bumping the byte
deliberately invalidates every graph.db on earth.
Bumping this constant forces a full re-index for every user. Version 2 exists because
RFC-014 made Phase A capture type-definition nodes and end_line spans that the
unification passes depend on; v1 databases simply lacked the nodes SCIP symbols had to
unify onto. The daemon's skew check and the travsr status warning both key off
this byte. Treat a bump as a breaking release.
EdgeKind: the typed relation lattice
Eleven kinds. Each carries a PageRank transition weight, and those weights are the entire reason travsr's PageRank is semantic rather than merely topological: a direct call propagates 3.3× the mass of a method override.
| Kind | Weight | Meaning | Produced by |
|---|---|---|---|
| ref/call | 1.00 | Call-site reference | SCIP/LSIF, native Phase B |
| ffi/call | 0.85 | Cross-language call | FFI resolver (RFC-005) |
| defines/binding | 0.70 | Parent defines child | Phase A |
| exports | 0.60 | Public API surface | Phase A |
| depends | 0.50 | File imports module | Phase A |
| resolves-to | 0.50 | Import → target file node | link_imports |
| ref/imports | 0.40 | Named import specifier | LSIF |
| is-implementation | 0.40 | Class implements interface | LSIF |
| configures | 0.35 | Config file → target | data-format parser |
| overrides | 0.30 | Method overrides base | LSIF |
| external-dependency | 0.30 | Config → registry package | data-format parser |
scale is irrelevant, ratios are not Weights are
normalised per source node at every PageRank iteration
(w(u→v) / Σ w(u→·)), so only the ratios between kinds affect the result.
Doubling every weight changes nothing. Governed by ADR-003.
Node & Edge
struct Node {
id: NodeId, // BLAKE3(VName), the primary key
vname: VName,
kind: String, // "function" | "method" | "class" | "import" | "file" | …
package: String, // sub-unit identity (ADR-005); NOT part of the hash input
line: Option<u32>, // 1-based start; None for synthetic nodes
end_line: Option<u32>, // span end, required by G2 attribution
}
struct Edge {
src: NodeId, dst: NodeId, kind: EdgeKind,
confidence: Option<u8>, // 0–100 for ffi/call (RFC-005); None otherwise
}
provenance and language are columns, not struct fields
edges.provenance ("tree-sitter" | "lsif"; lsif wins on conflict, ADR-002) and
edges.language live in SQLite, set at write time, not on the in-memory
Edge struct. Likewise access_corpus (RBAC scope) is a
nodes column added by migration v7, not a Node field.
why kind is a String, not an enum Node kinds come
from fifteen different grammars and from SCIP symbol descriptors. A closed enum would force
a workspace-wide change every time a grammar introduced a new construct. EdgeKind,
by contrast, is a closed enum: edges are the semantic contract of the graph and
adding one must be a deliberate, compiler-enforced decision.
3 · Storage design
SQLite in WAL mode, two files. No server, no daemon required for reads, nothing to configure. Current schema version: v20.
Index strategy
Indexes here are not decoration: several exist because their absence turned a query from minutes into hours on a monorepo. Each is justified:
| Index | On | Exists because |
|---|---|---|
| idx_nodes_vname | UNIQUE (corpus, root, path, language, signature) | Enforces one row per VName. The uniqueness is the identity guarantee at the storage layer. |
| idx_edges_src_kind_cov | (src, kind, dst) | v4. Covering index for forward traversal: PageRank reads neighbours without touching the main table. |
| idx_edges_dst_kind_cov | (dst, kind, src) | v14. Covering index for reverse traversal: get_callers, blast radius. v14 also drops the older, non-covering idx_edges_dst_kind from v1. |
| idx_nodes_corpus_path | (corpus, path) | v13. G1/G2 probe nodes by (corpus, path) once per SCIP reference. On kubernetes that is millions of probes; without this index each was a full table scan, hours instead of minutes. |
| idx_nodes_signature | (signature) | v19. nodes_by_signatures runs per unresolved-call batch on every commit; without it, every commit paid a full scan. |
Migration protocol
Nineteen migrations are registered, numbered 1–11 and 13–20. (v12 exists as a
.sql file but was never registered; it was the sqlite-vec approach
that v16 replaced with plain blobs.) The runner applies them in order and records progress
per version.
There is an atomicity gap between a migration's up() and the
set_schema_version that follows it. A crash in that window re-runs
up() on next open. Therefore: use CREATE TABLE IF NOT EXISTS,
CREATE INDEX IF NOT EXISTS, INSERT OR IGNORE. SQLite has no
ALTER TABLE … ADD COLUMN IF NOT EXISTS, so column additions must be guarded
manually with store.column_exists(table, col)?. Every existing migration does
this; a new one that does not is a latent corruption bug.
Write paths
nodes_stage / edges_stage, then
flush_staging_to_production moves everything in one transaction. FTS
insertion is deferred and rebuilt once from nodes_fts_map at the end,
maintaining the trigram index row-by-row during a cold index is the dominant cost.write_file_graphs_batch per changed file. Gated on the
files table: if the file's current SHA-256 matches its stored row, the file is
skipped entirely.write_scip_attributed_batch: takes ScipRef
occurrences and emits ref/call edges from the enclosing function
(binary search over span ranges) rather than the file node. See §4.delete_file returns a DirtySet, the paths that
held inbound edges to removed symbols, which the daemon enqueues for re-resolution.
reconcile and sweep_orphans back travsr fsck.why foreign keys are mostly off NodeIds are content
hashes written in large batches; enforcing referential integrity per row would dominate
indexing cost. Integrity is maintained by the writer's logic plus the
node_tombstones AFTER DELETE trigger. Only symbol_aliases carries a
real ON DELETE CASCADE. Migration v20 exists precisely because
this choice had a cost: edge_sites rows orphaned by pre-v20 deletions lingered
and surfaced as phantom occurrences in find_references, so v20 purges the
historical backlog while newer write paths clean up at the source.
4 · Indexing pipeline
Phase A vs Phase B
| Phase A | Phase B | |
|---|---|---|
| Engine | Tree-sitter, in-process | scip-go, scip-python, rust-analyzer, LSPs |
| Trust | Trusted, parses, executes nothing | Untrusted, may execute the repo's build scripts |
| Transport | In-process, zero IPC | Sandboxed subprocess, framed protocol |
| Cost | ~ms per file | Seconds to minutes, backgrounded |
| Produces | Definitions, imports, structure, spans | Cross-file call edges, type resolution |
| Blocking? | Yes, init waits | No, daemon schedules it after Phase A |
Rust, TypeScript, Python and Dart additionally have native Phase B:
tree-sitter-derived call edges with no external tool download at all. Calls it cannot resolve
(a bare cross-crate call whose target file is unknown, and the file path is part of the hash)
are returned as UnresolvedCall and resolved by the daemon against Phase A nodes
already in the store.
The unification problem: RFC-014
Phase B originally created a node population disjoint from the tree-sitter nodes every query resolves against. The 2026-06-11 forensics on a 613k-node kubernetes index:
tree-sitter fn/method nodes with ≥1 incoming ref/call: 14 / 139,114 (0.01%) ref/call edges whose SRC is a file node: 405,648 / 420,134 (96.6%) ref/call edges into SCIP anonymous locals: 272,102 (64.8%)
A graph that was rich and unreachable. get_callers answered with
anonymous file (file) rows. Three passes fixed it:
G1: Symbol unification
Each SCIP symbol is resolved onto the existing tree-sitter node for the same entity via
find_ts_node_for_unification, and the mapping is persisted in
symbol_aliases. No twin nodes are created.
G2: Call-site attribution
SCIP reference occurrences carry source ranges. Binary-searching the file's
function/method spans (which is why Phase A must capture end_line) attributes
each occurrence to its enclosing function. The ref/call edge is re-homed from
the file node to that function; the file node remains only as a fallback.
G3: Anonymous local filter
SCIP local N symbols are intra-function SSA temporaries carrying zero
developer-facing signal, and they were 34% of the edge table. They are now dropped at
ingest, and migration v13 deletes existing ones.
G2 depends on Phase A having recorded end_line for every definition. That
dependency is exactly why SIGNATURE_FORMAT_VERSION went from 1 to 2: v1
databases lack the nodes and spans the unification passes need, so they must be rebuilt
rather than migrated.
Pipeline, end to end
Phase A alone yields a complete structural graph: the tool is fully usable before Phase B ever runs. Phase B only adds call edges, and G1–G3 are what make those edges land on the same nodes Phase A created rather than on a parallel population.
Incremental re-index
git commit
└─ .git/hooks/post-commit
└─ travsr hook-run <changed files>
└─ try_dispatch_to_daemon() // unix socket / named pipe
├─ daemon alive → ControlMessage::ReindexCommit{sha}
└─ no daemon → in-process reindex_files()
├─ SHA-256 compare vs files table // unchanged → skip
├─ delete_nodes_for_path → DirtySet
├─ re-parse + write_file_graphs_batch
├─ enqueue_dirty_callers(DirtySet) // Tier-0 re-resolution
└─ set_meta("last_commit", sha)
5 · Plugin & sandbox design
One Plugin contract, two transports. This is the trust
boundary of the entire system (RFC-011 + ADR-017).
Wire format
Length-prefixed frames over the subprocess's stdio.
0xFFFFFFFF to force a 4 GiB allocation. The decoder therefore
rejects oversized frames before allocating.The bound is 1 GiB rather than something smaller because measurement forced it: the kubernetes monorepo's Go
InvokeResponse is ~288 MB once G2 refs are included.
Repos beyond ~1 GiB need a streaming protocol, tracked as follow-up.
The boundary
Everything crossing the dashed line is length-prefixed and size-checked before allocation. Phase A stays on the left deliberately: it reads bytes and builds an AST, so paying IPC and sandbox cost for it would buy nothing: roughly 15 s of pure transport overhead on a 50k-file repo, for zero security gain.
Defence in depth
OS sandbox
Linux bubblewrap, filesystem confined; network is intentionally
allowed (ADR-017 Amendment A1), Phase B tools that fetch dependencies need it.
macOS seatbelt (sandbox-exec).
Windows Job Objects + AppContainer.
Trust gate
Phase B spawns only after an explicit per-corpus grant
(~/.travsr/lang.toml or TRAVSR_TRUST_<CORPUS>=1).
Default is no Phase B. (ADR-017 Rule 3)
Bounded I/O
Watchdog timeouts, a bounded stderr_ring buffer, and frame caps. A wedged
plugin degrades that language's edges; it cannot deadlock indexing.
--allow-unsandboxed-lsif lets rust-analyzer run unconfined when no OS sandbox
is available. It is an explicit, per-invocation CLI flag by design: it can never be
set from .env, Cargo.toml, tsconfig.json or any other
file inside the repository being indexed. Without the flag, the LSIF pass is skipped and Rust
edges degrade to tree-sitter structural edges, a quality loss, never a security one.
why one sandbox policy, not one per tool The pre-ADR-017 design required a separate ADR per language indexer (scip-java, scip-typescript, scip-kotlin…). The threat is identical in every case: a semantic tool executing the indexed repo's build scripts, so re-arguing it per language was process cost with no correctness benefit. One policy now covers all sixteen.
6 · Retrieval design
The read path is where quality is won or lost. This is the full
get_context pipeline with exact parameters.
Stage order
get_context(query, token_budget) 1 validate_mcp_arg SEC-002, reject before touching the store 2 embed warmup + KNN 600 ms circuit breaker 3 build_seed_set IDF anchors + BM25 + KNN → RRF fusion 4 cross-encoder rerank inside seed selection; feeds PPR weight (RFC-022); 1200 ms breaker 5 ABSTAIN GATE Confidence::None → return resolution map 6 enrich_seeds_with_callers depth-1 (15), depth-2 (10) 7 dedup_adjacent_seeds protect PPR teleportation mass 8 ppr_weighted α = 0.85, ε = 1e-6, ≤ 50 iterations 9 k-core shell boost buried-middle recovery 10 knapsack 0-1 DP, fits token_budget 11 sanitize + envelope SEC-001
Reranking happens inside seed selection, before PageRank runs, not as a final re-sort. Its score feeds each seed's PPR personalization weight (RFC-022), so it shapes where PageRank starts rather than just reordering a finished list. Note where the two circuit breakers sit and what happens when they fire: neither aborts the request. The KNN result is discarded and lexical seeds carry the query; the reranker is skipped and seeds keep their RRF-fused weight. The only branch that terminates early is the abstention gate.
Seed selection & the confidence lattice
Three independent candidate sources are fused by Reciprocal Rank Fusion. RRF deliberately uses rank, not score, so sources with incomparable scales (BM25 vs cosine) can be combined without normalisation.
1/(k+1).Ties are broken by
NodeId ascending, per Invariant 4.Env:
TRAVSR_RRF_K
The fused set is then classified into one of four confidence levels, which decides whether the query is answered at all:
| Level | Roughly means | Behaviour |
|---|---|---|
| Exact | A rare literal anchor matched | Answer, no caveat |
| Strong | Coverage ≥ 0.6 and a real BM25 or semantic anchor | Answer, no caveat |
| Weak | Coverage ≥ 0.25, structural only | Answer with an explicit "may not be relevant" note |
| None | Nothing grounded the query | Abstain: return the term-resolution map, plus at most N speculative guesses |
The two dashed overlays are what make this more than a threshold ladder.
Promotion lets a purely conceptual query (one with no literal symbol in it)
reach Strong on embedding evidence alone. Veto does the reverse: it
demotes a Weak that rests only on coincidental lexical overlap when the embedding is
confident nothing in the corpus is actually near the query. Veto is what stops
"get the current weather forecast" from matching hundreds of get_*
functions.
| Threshold | Default | Env override | Why this number |
|---|---|---|---|
| coverage_strong | 0.60 | TRAVSR_COVERAGE_STRONG | Fraction of content tokens that must resolve |
| coverage_weak | 0.25 | TRAVSR_COVERAGE_WEAK | Floor below which nothing is claimed |
| rare_anchor_max | 3 | TRAVSR_RARE_ANCHOR_MAX | Symbol frequency at or below which an anchor is "rare" (IDF ≈ 1.0) |
| idf_coverage_min | 0.55 | TRAVSR_IDF_COVERAGE_MIN | Below this a token is too common to count as signal, keeps get, map, list from grounding a query |
| bm25_strong_floor | 0.50 | TRAVSR_BM25_STRONG_FLOOR | FTS5 -bm25() ranges ~0.1 (weak) to 3+ (strong) on this corpus |
| semantic_promote_strong | 0.72 | TRAVSR_SEMANTIC_PROMOTE_STRONG | Sits in the measured gap between answerable (≥0.77) and nonsense (≤0.64) on bge-small |
| semantic_veto_floor | 0.55 | TRAVSR_SEMANTIC_VETO_FLOOR | Below this the embedding is confident nothing in the corpus is near the query, vetoes a coincidental Weak |
The absolute cosine floors above were calibrated on bge-small. When arctic-embed-256 was introduced its entire answerable band topped out near 0.56, below floors of 0.66 and 0.72, so every conceptual query abstained by construction. The fix was auto-calibration against measured per-model anchors (nonsense p95 and self-match p50) rather than fixed constants. Any new embedding model must be re-calibrated, not merely swapped in.
Personalized PageRank
Power iteration over a subgraph materialised once by BFS, so the inner loop never touches the store.
1 − α teleports back to seeds.
Env TRAVSR_PPR_ALPHA, accepted only in (0,1) exclusive.p(v): personalisation vector. Uniform in
ppr(); proportional to seed
weight (cosine × kind_boost × confidence) in ppr_weighted().w: the EdgeKind weight from §2, normalised per source node.
Convergence: stop when ‖rt+1 − rt‖₁ < ε = 1e-6, hard cap 50 iterations. Typically converges in 15–30.
Complexity: O(iterations × |Ereachable|) time, O(|Vreachable|) space.
guarded constants MAX_ITERATIONS carries
a compile-time const _: () = assert!(…) that fires on every build, not just in
tests, so an accidental edit is caught before release. Degenerate weight vectors (sum ≤ 0 or
non-finite) fall back to uniform rather than producing NaN scores. Dangling
nodes are explicitly inserted into r_next so convergence is still checked
against them.
Knapsack budget enforcement
The final stage picks the highest-value subset of ranked nodes that fits the caller's token budget, a 0-1 knapsack, not a truncation.
| Constant | Value | Role |
|---|---|---|
| TOKEN_CHARS_PER_TOKEN | 4 | Median chars/token for cl100k_base. Item weight = (sig + kind + path) / 4, minimum 1. Path is included because the model needs it to locate the symbol. |
| SCORE_SCALE | 1 000 000 | f32 PPR score → u32 for integer DP. 1e6 preserves scores as low as 0.001 that 1e3 would round to zero. |
| DP_CELL_LIMIT | 500 000 | Above n × budget = this (≈2 MB table), fall back to greedy value/cost ratio. |
| MAX_CONTEXT_BUDGET | 32 000 | Hard ceiling. A larger request returns empty rather than allocating. |
| context_candidates | 200 | Nodes considered. Env TRAVSR_CONTEXT_CANDIDATES. |
why a 2-D DP table A rolling 1-D array computes the optimal value in less memory but cannot reconstruct which items were chosen. Backtracking needs the full table, so the implementation keeps it, and switches to greedy only when the table would exceed the cell limit.
7 · MCP interface
MCP is the only external interface. No REST, no GraphQL.
Framing is newline-delimited JSON-RPC 2.0 over stdin/stdout; all diagnostics go to stderr via
tracing, because stdout is the protocol channel.
Input validation: SEC-002
Applied before any store access, on every tool argument.
| Rule | Value |
|---|---|
| Max scalar argument | 512 bytes |
| Max list argument | 4 096 bytes |
| Rejected patterns | ../ ..\ absolute paths NUL bytes %-encoded traversal |
Output sanitisation: SEC-001
Tool output is untrusted content from an untrusted repository heading directly into a language model's context. Four steps, in order:
Truncate
To MAX_OUTPUT_BYTES = 4 096.
Strip C0/C1 control characters
Removes terminal escapes and invisible framing tricks.
Escape < and >
Prevents XML/HTML injection into tool descriptions and prompt structure.
Wrap in <travsr-data>
A structural envelope so the model can tell retrieved data from instructions.
"Symbol not found" and "access denied" return the same response. An RBAC-scoped
session must not be able to probe for the existence of symbols outside its corpus by
comparing error messages. Enforced through the EdgeFilter trait
(OpenFilter vs RbacFilter) and guarded by a dedicated CI job,
rbac-leak-gate.yml.
known gap The SSE/HTTP transport still constructs an
OpenFilter rather than a per-session RbacFilter; the TODO is
marked in sse.rs. The session model, the access_corpus column and
the leak-test suite all exist; only the wiring on that transport is outstanding.
8 · Process & concurrency model
Two databases exist for one reason: lock separation. Embedding writes are
slow and bulky, and putting them in graph.db would have the embed sidecar
holding the WAL writer lock exactly when a commit needs it. The dashed path is the
no-daemon fallback: every read path works without any background process running.
Processes
embed.db exclusively, so slow vector writes never block
the main graph.Control-plane protocol
Unix domain socket at .travsr/daemon-<hex>.sock (Windows: named pipe).
Messages are JSON tagged by an op field in kebab-case.
enum ControlMessage { // {"op":"reindex-commit","sha":"…"}
ReindexCommit { sha },
ReindexPaths { paths },
Status, Shutdown,
StopEmbed, ResumeEmbed, // pause/resume auto-reindex + cancel in-flight embed
Query { protocol, tool, args },
}
const QUERY_PROTOCOL_VERSION: u32 = 1;
A daemon older than a given variant fails to parse the line and answers with a parse
error. The CLI reads that as "route unavailable" and falls back to opening the store
directly. Likewise a Query whose protocol does not match is refused
rather than deserialised into a differently-shaped payload. Skew costs performance, never
correctness.
Concurrency hazards & how each is contained
| Hazard | Containment |
|---|---|
Two writers on graph.db | SQLite WAL permits one writer and many concurrent readers. The daemon owns writes; the MCP server opens read-only. A CLI writing without a daemon takes the same single writer lock. |
| Slow embed writes blocking a commit | Embeddings live in a separate file with a separate lock. The embed sidecar is its sole writer. |
| Two daemons on one repo | Singleton enforced via socket path + lock file; covered by memory_and_singleton.rs. |
| Re-index racing a query | Readers see a consistent WAL snapshot. Worst case a query answers from the pre-commit graph, and the response header names the commit it reflects, so staleness is visible rather than silent. |
| Wedged plugin holding the pipeline | Watchdog timeout plus bounded stderr ring. Killing one language's sidecar does not stall the others. |
| Watcher event storm (branch switch) | Events are debounced and coalesced into a dirty set rather than dispatched one-per-file. |
| Non-deterministic ranking | Every ordering that can tie breaks on NodeId ascending. Without it HashMap iteration order leaks into output and benchmarks stop being reproducible. |
Error taxonomy: ADR-004
Libraries use thiserror with a closed enum per layer; the CLI uses
anyhow. Errors do not cross layers untranslated.
TravsrError // public surface
├─ InvalidParams(String)
├─ Store(StoreError) ├─ Database · Migration · Io
├─ Index(IndexError) ├─ Parse{file,message} · Lsif · Io
│ ├─ PhaseNotSupported
│ ├─ ProtocolVersionMismatch{expected,got}
│ ├─ UnknownLanguage{reported}
│ └─ PluginCrashed{language}
├─ Retrieval(RetrievalError)├─ Traversal · Store
│ └─ PprDivergence{iterations}
├─ BudgetExceeded{requested, limit}
└─ Internal
plugin errors are first-class Four of the index-error variants describe plugin misbehaviour specifically: wrong protocol version, unknown language, crash, unsupported phase. That is the type system encoding Invariant 6: a plugin is an untrusted peer, so every way it can misbehave gets a name rather than collapsing into a generic I/O error.
Observability
Tracing spans
Structured tracing throughout. Notable fields: ppr.iteration,
ppr.delta, ppr.nodes_scored, knn_elapsed_ms.
Filter with RUST_LOG.
In-band signals
Every get_context response carries a header naming the indexed commit, the
embed state, coverage n/m, and the confidence label. The answer reports its
own trustworthiness.
Diagnostics
seed_trace and embed_knn_probe are unlisted MCP tools,
callable by name, absent from tools/list, and no shipping path depends on
them. OTLP export is available behind an otlp feature.
stdout is reserved Libraries may never
println!. Stdout is the JSON-RPC channel; a stray print corrupts the protocol
stream. All diagnostics go to stderr.
9 · Threat model
The adversary is a repository under index. It controls file contents, file names, directory structure, build manifests, and anything a semantic tool would execute. It does not control the user's CLI invocation.
| Attack | Vector | Mitigation |
|---|---|---|
| Arbitrary code execution | A build script run by a semantic indexer (build.rs, package.json lifecycle scripts, Gradle) | Phase B runs only inside an OS sandbox, and only after an explicit per-corpus trust grant. No sandbox → the pass is skipped, not run unconfined. |
| Escalation via CLI flag | Repo file that sets --allow-unsandboxed-lsif | Structurally impossible: it is a per-invocation CLI flag and is never read from repository contents. |
| Memory exhaustion | Forged 4-byte length prefix on the plugin wire (0xFFFFFFFF → 4 GiB alloc) | Frame length validated against MAX_FRAME_LEN before allocation. |
| Prompt injection | Source comment or identifier crafted to issue instructions once it reaches an LLM's context | Output truncated, C0/C1 stripped, </> escaped, wrapped in a <travsr-data> envelope so data is structurally distinguishable from instructions. |
| Path traversal | ../, absolute paths, NUL bytes, %-encoded sequences in a tool argument | Rejected at dispatch by validate_mcp_arg, before any store access. |
| Cross-corpus disclosure | RBAC session probing for symbols outside its scope | EdgeFilter at the query layer; "not found" and "denied" are byte-identical responses. CI gate rbac-leak-gate.yml. |
| Hash collision / identity confusion | Crafted VName fields designed to collide | Length-prefixed BLAKE3 encoding is injective; a version byte keeps id spaces from different formats disjoint. |
| Denial of service via query | Enormous token_budget or a pathological graph | MAX_CONTEXT_BUDGET, DP_CELL_LIMIT with greedy fallback, PPR iteration cap, per-stage circuit breakers. |
travsr does not defend against a malicious user on their own machine, a
compromised Rust toolchain or crates.io dependency, or an MCP client that mishandles the
<travsr-data> envelope. The sanitiser makes injected content structurally
identifiable; it cannot force a downstream model to respect that structure.
10 · Failure modes & degradation
Every optional subsystem has a defined, visible failure behaviour. This table is the contract.
| Subsystem | Failure | Behaviour | Signal to the user |
|---|---|---|---|
| Embed KNN | Exceeds 600 ms | Results discarded; FTS seeds only | embeddings: degraded |
| Embed sidecar | Not yet armed | Brief blocking wait, then proceed lexical-only | embeddings: warming |
| Embed index | Never built | Lexical-only path, no error | embeddings: off |
| Cross-encoder | Model absent or > 1200 ms | Cached as unavailable; PPR order stands | rerank: not installed |
| Phase B plugin | Wedged or crashed | Watchdog kills it; that language keeps structural edges | get_lang_status |
| OS sandbox | Unavailable | LSIF pass skipped unless explicitly overridden | Warning at init |
| Daemon | Not running | CLI opens the store directly | Silent (slower) |
| Seed grounding | Nothing matched | Abstain with resolution map | confidence: none |
Everything above fails open: reduced quality, still useful. The sandbox is the exception: if no OS sandbox is available, the untrusted LSIF pass does not run at all. Quality is allowed to degrade silently; the trust boundary is not.
Why breakers measure after, not before
deliberate trade-off Both the KNN and rerank breakers
time the call and discard the result if it ran long, rather than pre-empting it. A CPU-bound
ONNX forward pass cannot be safely aborted mid-flight without unsafe thread termination, and
unsafe is forbidden or denied in every crate. The real defence against cost is
bounding the input: MAX_CANDIDATE_CHARS caps each candidate's tokenizer
input, which is what made rerank cost roughly repo-independent. Before that fix, K=30 on
kubernetes measured 2.5–9 s (40-line Go snippets blew past truncation and
BatchLongest padding then inflated the whole batch); after, 700–950 ms.
11 · Performance envelope
Measured, not estimated. Small-repo numbers below are from indexing travsr's own tree with a release build on an Apple Silicon laptop; the kubernetes numbers are from the checked-in benchmark reports.
Cold index (self)
420 files → 8 195 nodes, 8 433 edges, in ~1 s. Phase A only; Phase B is scheduled to the background.
Query latency
116–602 ms warm on travsr itself. 1.4–3.8 s on kubernetes. Cold first query: 2.1 s / 6.5 s respectively.
PPR
Converges in 15–30 iterations at α = 0.85. Subgraph is materialised once so the iteration loop never touches SQLite.
Retrieval quality: the honest numbers
| Corpus | hit@1 | hit@3 | MRR | Abstain on nonsense |
|---|---|---|---|---|
| travsr (self) | 0.292 | 0.333 | 0.312 | 2/3 |
| kubernetes/kubernetes | 0.208 | 0.250 | 0.250 | 3/3 |
Broken down by query category on kubernetes, the shape is unmistakable: literal queries 6/6 hit; conceptual queries 7/8 miss. Literal symbol lookup is solved. Conceptual recall is the open problem, and the abstention gate is currently tuned slightly too conservatively: it correctly refuses all nonsense, and also refuses conceptual queries it should answer.
Any change touching seed selection, fusion, ranking, or the reranker must ship
before/after numbers from node bench/run.mjs. This is not a suggestion: every
bench/report-*.md in the repository is exactly such a comparison, and that is
the review expectation.
Capacity limits & where it breaks
| Limit | Value | What happens at the edge |
|---|---|---|
| Plugin frame | 1 GiB | Hard reject. Kubernetes' Go InvokeResponse is ~288 MB with G2 refs, so headroom is ~3.5×. Beyond this a streaming protocol is required. |
| token_budget | 32 000 | Returns empty with an explicit message rather than allocating. |
| Knapsack DP | 500 000 cells | Silently switches to greedy value/cost. Near-optimal, no longer provably optimal. |
| PPR iterations | 50 | Returns the current vector unconverged. Real graphs converge in 15–30. |
| MCP scalar arg | 512 B | Rejected before store access. |
| Tool output | 4 096 B | Truncated, with truncation signalled in the response. |
| NodeId space | 64-bit | Birthday collision becomes non-negligible near ~5×10⁹ nodes, far past any tested corpus, but it is a real ceiling and not a theoretical one. |
Configuration surface
Roughly 70 TRAVSR_* environment variables exist. They are an
experimentation surface, not a production configuration API; ADR-003 says
so explicitly for the PageRank group. The ones that matter for tuning:
| Group | Variables |
|---|---|
| PageRank | PPR_ALPHA · PPR_EPSILON · PPR_MAX_ITER |
| Seed / confidence | COVERAGE_STRONG · COVERAGE_WEAK · RARE_ANCHOR_MAX · IDF_COVERAGE_MIN · BM25_STRONG_FLOOR · RRF_K |
| Semantic floors | SEMANTIC_PROMOTE_STRONG · SEMANTIC_VETO_FLOOR · SEMANTIC_RECALL_FLOOR · CONFIRM_ANCHOR_FLOOR · SEMANTIC_PROMOTE_MIN_NEAR · DISJOINT_RESCUE_COS |
| Budgets | KNN_BUDGET_MS · RERANK_BUDGET_MS · EMBED_ARM_WAIT_MS · CONTEXT_CANDIDATES · RERANK_TOPK |
| Resource governance | EMBED_WORKERS · EMBED_CAPACITY · EMBED_PRIORITY · STORE_CACHE_MB · STORE_MMAP_GB · REINDEX_TIMEOUT_SECS |
| Trust / security | TRUST_<CORPUS> · ALLOW_UNSANDBOXED_LSIF · SIGNING_KEY_HEX · VAULT_SECRET_OCID |
Testing strategy
~1 300 tests
Mostly inline #[cfg(test)] beside the code they cover, which is why crate
line counts read high.
Property tests
proptest on PCST: the algorithm most likely to be subtly wrong in ways an
example test would not surface.
Golden fixtures
Per-language parse fixtures. Adding a language means adding one; grammar equivalence is checked across transports.
Fuzzing
Parser fuzz targets plus an MCP protocol target. registry.rs still carries
TODOs for nine languages, but fuzz targets have since been added for eight of them;
Objective-C is the one genuine gap left.
Security gates
Dedicated CI jobs: rbac-leak-gate, sandbox-windows,
osv-scan, cross-lang-gate.
Benchmarks as tests
bench/run.mjs drives a real MCP server over stdio and scores hit@k / MRR /
abstention against labelled query sets.
Alternatives considered and rejected
| Alternative | Why it was rejected |
|---|---|
| Kùzu graph database | Dropped in ADR-018. A native graph DB sounds like the obvious fit, but it added a dependency, a second storage code path, and a parity test harness, for a workload SQLite already served. RocksDB stays open as a future hyperscale option. |
| Uniform subprocess model | Would push every Phase A file parse through IPC: ~15 s of pure transport on a 50k-file repo, paid for parsing that executes no untrusted code. Rejected in RFC-011 in favour of two transports. |
| TOML language descriptors | RFC-008 proposed a declarative descriptor format with its own versioning and trust tiers. A plugin that self-describes at runtime through a handshake removes the format entirely. |
| Per-tool sandbox ADRs | The pre-ADR-017 chain required a fresh ADR per language indexer. The threat is identical in every case, so this was process cost with no correctness benefit. |
| sqlite-vec extension | Migration v12 was written for it, then never registered; it required loading a native extension into the main process. Replaced by plain blobs in v16 plus a sidecar that owns its own file. |
| In-memory HNSW (hnsw_rs) | Materialised every vector before the first insert: ~6.8 GB at 500k nodes, OOM near 800k. Unusable on a laptop, which is the target environment. |
| Rolling 1-D knapsack | Uses less memory but cannot reconstruct which items were selected. Backtracking needs the full table. |
| Pre-emptive circuit breakers | Aborting a CPU-bound ONNX pass mid-flight needs unsafe thread termination. unsafe is forbidden or denied in every crate, so breakers measure after and discard instead. |
Known gaps
- Conceptual recall. 7 of 8 conceptual queries miss on kubernetes. The largest open quality problem.
- Abstention is over-tuned. Nonsense is refused 3/3, and so are conceptual queries that should be answered.
- Salad queries leak. Generic verbs (
get,insert) still collide with real symbols;idf_coverage_minalone is not sufficient. - SSE transport lacks per-session RBAC. Every other piece exists; only the wiring is missing.
- Objective-C has no fuzz target. The last of nine
registry.rsTODOs still open. - Synonym infrastructure is unused. Tables, MCP tools and CLI commands all ship, but the retrieval path barely exploits them, a ready-made lever for the recall problem above.
12 · Extension points
The four things most likely to be added, and what each one costs.
A new language
Cheapest. RFC-011's stated goal is "one plugin crate + one golden fixture". Most languages are driven by the config-based generic Phase A path; only high-complexity or FFI-aware grammars need hand-written parsers. Add the tree-sitter grammar, a plugin module, and a fixture. Phase B is optional and separate.
A new edge kind
Deliberately expensive. EdgeKind is a closed enum, so the
compiler forces every match site to be updated, including ppr_weight, where
you must justify the new weight against ADR-003. That friction is the feature.
A new MCP tool
Add the handler in tools.rs, register it in both dispatchers
(single-repo and global), declare its JSON schema per RFC-004, and route arguments through
validate_mcp_arg and the sanitiser. Several tools are one graph query away
from existing: type hierarchy (is-implementation + overrides
edges are already stored but unexposed), diff-scoped blast radius, structural similarity.
A new retrieval algorithm
travsr-retrieval depends only on core, error and store; it is the safest
place in the codebase to experiment. Add the module, benchmark it against
bench/queries.json, and wire it into get_context_body behind an
env flag before making it the default.
travsr-analysis depends on travsr-core only; no other
travsr crate may appear in its manifest. travsr-plugin-host is the
only crate in the indexer tier permitted to depend on
travsr-plugin-protocol. These keep parsing pure and the trust boundary in one
place; both are stated in the crates' own module documentation.
House rules, enforced by CI
- No
unwrap()orexpect()anywhere incrates/excepttravsr-cli unsafeis forbidden or (intravsr-plugin-host, weaker on purpose) denied in every crate that declares the lint; the single override, Windows sandbox FFI, required an RFC- No
println!in libraries;tracingonly, because stdout is the MCP channel cargo clippy --workspace --all-targets -- -D warningsmust pass- Constants traceable to an RFC or ADR may not be changed without updating that document
Parse source into a typed graph in SQLite → seed that graph from a query (lexical + semantic, rank-fused, behind an abstention gate) → propagate with weighted PageRank → re-rank → pack into a token budget with a knapsack → hand the agent real code joined by real edges.
Everything on this page was read from the Rust
source in crates/. Where a number is stated it was taken from the constant that
defines it or from a checked-in benchmark report, not from a roadmap.