Architecture · Low-Level Design (LLD)

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
  2. Identity & the data model
  3. Storage design
  4. Indexing pipeline
  5. Plugin & sandbox design
  6. Retrieval design
  7. MCP interface
  8. Process & concurrency model
  9. Threat model
  10. Failure modes & degradation
  11. Performance envelope
  12. Extension points

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.

Invariant 1: Algorithms first, LLM last

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.

Invariant 2: Transport is orthogonal to edge determination

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)

Invariant 3: Identity is content-addressed and versioned

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)

Invariant 4: Determinism in the read path

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.

Invariant 5: Honest degradation

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.

Invariant 6: The indexed repository is untrusted input

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.

CLIENTS Claude / Cursor VS Code ext terminal user INTERFACE travsr-mcp · JSON-RPC 2.0 / stdio travsr-cli · clap travsr-daemon · watcher · hooks control plane, spans read and write READ PATH travsr-retrieval PPR · PCST · knapsack · k-core travsr-rerank ONNX cross-encoder WRITE PATH travsr-analysis 15 tree-sitter grammars travsr-indexer SCIP / LSIF ingest travsr-plugin-host TRUST BOUNDARY · sandbox STORAGE travsr-store graph.db embed.db SQLite + WAL, no server FOUNDATION travsr-core · travsr-error · travsr-config · travsr-ipc schedules

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.

NodeId = u64_le( BLAKE3( V ‖ len₃₂(c)‖c ‖ len₃₂(r)‖r ‖ len₃₂(p)‖p ‖ len₃₂(l)‖l ‖ len₃₂(s)‖s )[0..8] ) V = SIGNATURE_FORMAT_VERSION, a single byte, currently 2
len₃₂ = 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.

Change protocol: SIGNATURE_FORMAT_VERSION

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.

KindWeightMeaningProduced by
ref/call1.00Call-site referenceSCIP/LSIF, native Phase B
ffi/call0.85Cross-language callFFI resolver (RFC-005)
defines/binding0.70Parent defines childPhase A
exports0.60Public API surfacePhase A
depends0.50File imports modulePhase A
resolves-to0.50Import → target file nodelink_imports
ref/imports0.40Named import specifierLSIF
is-implementation0.40Class implements interfaceLSIF
configures0.35Config file → targetdata-format parser
overrides0.30Method overrides baseLSIF
external-dependency0.30Config → registry packagedata-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:

IndexOnExists because
idx_nodes_vnameUNIQUE (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.

Hard requirement: every migration must be idempotent

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

Bulk init
Writes into 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.
Incremental
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.
Phase B merge
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.
Deletion / GC
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 APhase B
EngineTree-sitter, in-processscip-go, scip-python, rust-analyzer, LSPs
TrustTrusted, parses, executes nothingUntrusted, may execute the repo's build scripts
TransportIn-process, zero IPCSandboxed subprocess, framed protocol
Cost~ms per fileSeconds to minutes, backgrounded
ProducesDefinitions, imports, structure, spansCross-file call edges, type resolution
Blocking?Yes, init waitsNo, 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.

Consequence

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

source files walk + .travsrignore PHASE A: TRUSTED, IN-PROCESS tree-sitter parse nodes + spans link_imports resolves-to edges ~ms / file · parallel across cores PHASE B: UNTRUSTED, SANDBOXED scip / lsif tool bwrap · seatbelt native phase B rs · ts · py · dart seconds–minutes · background · trust grant required skipped entirely if no OS sandbox RFC-014 UNIFICATION G1 · symbol unify G2 · call-site attrib G3 · drop locals graph.db unified graph nodes refs daemon resolves UnresolvedCall against Phase A nodes

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.

[ 4-byte length, big-endian ] [ JSON payload ] MAX_FRAME_LEN = 1 GiB. The length prefix is attacker-controlled: a hostile plugin could send 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

TRUSTED: travsr's own process travsr-daemon travsr-analysis · tree-sitter (Phase A) parses bytes · executes nothing · needs no sandbox travsr-store · graph.db travsr-plugin-host owns the boundary · supervises · decodes frames TRUST BOUNDARY UNTRUSTED: sandboxed subprocesses scip-go / scip-python rust-analyzer / LSPs these may execute the indexed repository's build scripts OS confinement Linux · bubblewrap, filesystem confined (network allowed) macOS · seatbelt · Windows · Job Objects + AppContainer Containment if it misbehaves watchdog timeout · bounded stderr ring · 1 GiB frame cap 4-byte BE length prefix + JSON, both directions

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.

Cannot be set by repository contents

--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
AI client mcp/server seed.rs store embed sidecar retrieval rerank get_context(query, budget) validate_mcp_arg: reject before any store access KNN(query, k): 600 ms breaker armed (node, cosine)[] · discarded if over budget build_seed_set(query, knn) per-token IDF anchors FTS5 trigram BM25 candidates RRF fuse 3 sources → coverage → confidence rerank fused candidates · 1200 ms breaker scores · feed PPR personalization weight (RFC-022); skipped if model absent ALT: confidence == None abstain: term-resolution map, no results SeedSet { seeds, coverage, confidence } ppr_weighted(seeds) · α=0.85, ε=1e-6, ≤50 iter materialise subgraph once (BFS) scored candidates + k-core shell boost knapsack → sanitize → <travsr-data>

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.

RRF(d) = Σs ∈ sources 1 / (k + ranks(d) + 1) rank is 0-based, so the top hit of a source contributes 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:

LevelRoughly meansBehaviour
ExactA rare literal anchor matchedAnswer, no caveat
StrongCoverage ≥ 0.6 and a real BM25 or semantic anchorAnswer, no caveat
WeakCoverage ≥ 0.25, structural onlyAnswer with an explicit "may not be relevant" note
NoneNothing grounded the queryAbstain: return the term-resolution map, plus at most N speculative guesses
fused candidates rare anchor? freq ≤ 3 Exact answer, no caveat yes no coverage ≥ 0.60 + bm25 / cosine Strong answer, no caveat yes no coverage ≥ 0.25 Weak answer + relevance caveat yes None → ABSTAIN return resolution map no SEMANTIC VETO oracle top-cosine < 0.55 and no rare exact anchor was named → demote SEMANTIC PROMOTION cosine ≥ 0.72 with ≥ N near neighbours grounds a query with no lexical anchor at all

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.

ThresholdDefaultEnv overrideWhy this number
coverage_strong0.60TRAVSR_COVERAGE_STRONGFraction of content tokens that must resolve
coverage_weak0.25TRAVSR_COVERAGE_WEAKFloor below which nothing is claimed
rare_anchor_max3TRAVSR_RARE_ANCHOR_MAXSymbol frequency at or below which an anchor is "rare" (IDF ≈ 1.0)
idf_coverage_min0.55TRAVSR_IDF_COVERAGE_MINBelow this a token is too common to count as signal, keeps get, map, list from grounding a query
bm25_strong_floor0.50TRAVSR_BM25_STRONG_FLOORFTS5 -bm25() ranges ~0.1 (weak) to 3+ (strong) on this corpus
semantic_promote_strong0.72TRAVSR_SEMANTIC_PROMOTE_STRONGSits in the measured gap between answerable (≥0.77) and nonsense (≤0.64) on bge-small
semantic_veto_floor0.55TRAVSR_SEMANTIC_VETO_FLOORBelow this the embedding is confident nothing in the corpus is near the query, vetoes a coincidental Weak
These floors are model-relative, and that has bitten before

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.

rt+1(v) = (1 − α)·p(v)  +  α · Σu→v [ w(u→v) / Σu→· w ] · rt(u) α = 0.85: follow-edge probability; 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.

ConstantValueRole
TOKEN_CHARS_PER_TOKEN4Median 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_SCALE1 000 000f32 PPR score → u32 for integer DP. 1e6 preserves scores as low as 0.001 that 1e3 would round to zero.
DP_CELL_LIMIT500 000Above n × budget = this (≈2 MB table), fall back to greedy value/cost ratio.
MAX_CONTEXT_BUDGET32 000Hard ceiling. A larger request returns empty rather than allocating.
context_candidates200Nodes 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.

RuleValue
Max scalar argument512 bytes
Max list argument4 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.

Security property: indistinguishable failures

"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

travsr CLI short-lived git post-commit hook-run MCP server spawned by client travsr-daemon singleton per repo · file watcher (notify) · phase-B scheduler · query cache · warm store plugin sidecars per language, sandboxed embed sidecar sole writer of embed.db graph.db WAL · 1 writer, N readers embed.db separate file, separate lock unix socket writes embed-KNN hook injected by daemon fallback: direct read-only open when no daemon

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

CLI
Short-lived. Tries the daemon first; falls back to opening the store directly when no daemon is reachable.
Daemon
Long-lived. Owns the file watcher, the git-hook dispatch, the Phase B scheduler, the query cache, and the warm store handle. Singleton per repo.
MCP server
Spawned by the AI client over stdio. Read-only against the store; may receive an injected embed-KNN hook from the daemon.
Plugin sidecars
Sandboxed, per-language, supervised. Killed and restarted on watchdog timeout without affecting other languages.
Embed sidecar
Owns 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;
Version-skew policy: degrade, never mis-render

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

HazardContainment
Two writers on graph.dbSQLite 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 commitEmbeddings live in a separate file with a separate lock. The embed sidecar is its sole writer.
Two daemons on one repoSingleton enforced via socket path + lock file; covered by memory_and_singleton.rs.
Re-index racing a queryReaders 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 pipelineWatchdog 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 rankingEvery 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.

AttackVectorMitigation
Arbitrary code executionA 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 flagRepo file that sets --allow-unsandboxed-lsifStructurally impossible: it is a per-invocation CLI flag and is never read from repository contents.
Memory exhaustionForged 4-byte length prefix on the plugin wire (0xFFFFFFFF → 4 GiB alloc)Frame length validated against MAX_FRAME_LEN before allocation.
Prompt injectionSource comment or identifier crafted to issue instructions once it reaches an LLM's contextOutput 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 argumentRejected at dispatch by validate_mcp_arg, before any store access.
Cross-corpus disclosureRBAC session probing for symbols outside its scopeEdgeFilter at the query layer; "not found" and "denied" are byte-identical responses. CI gate rbac-leak-gate.yml.
Hash collision / identity confusionCrafted VName fields designed to collideLength-prefixed BLAKE3 encoding is injective; a version byte keeps id spaces from different formats disjoint.
Denial of service via queryEnormous token_budget or a pathological graphMAX_CONTEXT_BUDGET, DP_CELL_LIMIT with greedy fallback, PPR iteration cap, per-stage circuit breakers.
Explicitly out of scope

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.

SubsystemFailureBehaviourSignal to the user
Embed KNNExceeds 600 msResults discarded; FTS seeds onlyembeddings: degraded
Embed sidecarNot yet armedBrief blocking wait, then proceed lexical-onlyembeddings: warming
Embed indexNever builtLexical-only path, no errorembeddings: off
Cross-encoderModel absent or > 1200 msCached as unavailable; PPR order standsrerank: not installed
Phase B pluginWedged or crashedWatchdog kills it; that language keeps structural edgesget_lang_status
OS sandboxUnavailableLSIF pass skipped unless explicitly overriddenWarning at init
DaemonNot runningCLI opens the store directlySilent (slower)
Seed groundingNothing matchedAbstain with resolution mapconfidence: none
Note the one fail-closed case

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

Corpushit@1hit@3MRRAbstain on nonsense
travsr (self)0.2920.3330.3122/3
kubernetes/kubernetes0.2080.2500.2503/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.

Benchmark protocol

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

LimitValueWhat happens at the edge
Plugin frame1 GiBHard reject. Kubernetes' Go InvokeResponse is ~288 MB with G2 refs, so headroom is ~3.5×. Beyond this a streaming protocol is required.
token_budget32 000Returns empty with an explicit message rather than allocating.
Knapsack DP500 000 cellsSilently switches to greedy value/cost. Near-optimal, no longer provably optimal.
PPR iterations50Returns the current vector unconverged. Real graphs converge in 15–30.
MCP scalar arg512 BRejected before store access.
Tool output4 096 BTruncated, with truncation signalled in the response.
NodeId space64-bitBirthday 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:

GroupVariables
PageRankPPR_ALPHA · PPR_EPSILON · PPR_MAX_ITER
Seed / confidenceCOVERAGE_STRONG · COVERAGE_WEAK · RARE_ANCHOR_MAX · IDF_COVERAGE_MIN · BM25_STRONG_FLOOR · RRF_K
Semantic floorsSEMANTIC_PROMOTE_STRONG · SEMANTIC_VETO_FLOOR · SEMANTIC_RECALL_FLOOR · CONFIRM_ANCHOR_FLOOR · SEMANTIC_PROMOTE_MIN_NEAR · DISJOINT_RESCUE_COS
BudgetsKNN_BUDGET_MS · RERANK_BUDGET_MS · EMBED_ARM_WAIT_MS · CONTEXT_CANDIDATES · RERANK_TOPK
Resource governanceEMBED_WORKERS · EMBED_CAPACITY · EMBED_PRIORITY · STORE_CACHE_MB · STORE_MMAP_GB · REINDEX_TIMEOUT_SECS
Trust / securityTRUST_<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

AlternativeWhy it was rejected
Kùzu graph databaseDropped 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 modelWould 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 descriptorsRFC-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 ADRsThe 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 extensionMigration 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 knapsackUses less memory but cannot reconstruct which items were selected. Backtracking needs the full table.
Pre-emptive circuit breakersAborting 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_min alone 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.rs TODOs 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.

Boundaries a contribution must respect

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() or expect() anywhere in crates/ except travsr-cli
  • unsafe is forbidden or (in travsr-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; tracing only, because stdout is the MCP channel
  • cargo clippy --workspace --all-targets -- -D warnings must pass
  • Constants traceable to an RFC or ADR may not be changed without updating that document
The whole system in one line

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.