Architecture
Rift works as a toolkit for agents such as Claude Code and Codex - practically any agentic appliance that supports MCP.
When an agent has Rift configured as one of its MCP servers, the rift mcp process starts and
checks whether a rift server is already running for that particular folder. We'll call this
folder a workspace throughout this documentation.
Each rift mcp process is short-lived and isolated, while development operations depend on
state shared across requests and agents. To bridge that gap, rift mcp acts as a small proxy: it
forwards every request to one rift server running locally for the workspace. The two processes
communicate over Streamable
HTTP.
The rift server coordinates source discovery, providers, and editing tools. Source
resolvers identify project files, dependencies, and generated code. Providers analyze that source
and produce facts such as symbols, syntax nodes, relationships, and diagnostics.
The server stores searchable provider output in an embedded index under .rift. The
search tool always retrieves lexical matches; when
rift.toml selects an embedding model, a local model adds vector ranking.
The server also owns edits. Its change tools resolve targets against provider facts and write the files. Hooks extend this pipeline: transforms such as formatters may change landed source, then validations such as linters and test runners report on final bytes.
Server lifecycle
Since v0.0.11When the server starts, it writes .rift/server.json following the
ServerLock model. The file gives each rift mcp
process the port and token it needs to reach that server. For example:
{
"port": 12345,
"token": "Zk3mQ8xW1vY5uT9rN2bH7cJ4dL6fP0sA8gE5iK2oM7q",
"pid": 4242,
"identity": {
"version": "0.0.25",
"executable_digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"schema_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}When no live server stands behind the file, rift mcp starts one. An exclusive file lock
elects one starter if several processes race; the winner binds a port and publishes the file,
and the rest connect to it.
The proxy adopts that server only when package version, executable SHA-256, and served MCP schema SHA-256 match its own identity. A mismatch is stale state, even when the recorded process and port are live, so requests never cross between binaries or tool schemas that interpret them differently.
A reader that cannot reach the recorded port treats the file as stale. The election lock releases with its holder, so a stale file never has a live server behind it, and the next starter replaces the file.
To enforce a cross-process boundary between OS users, the server persists a token in
.rift/server.json, which only the file's owner can read. Every request carries this token,
and the server rejects one without it. Processes running as the same OS user can read the same
token and are not separated by this boundary.
A browser introduces another route to a localhost port: a remote page can issue requests through
DNS rebinding or a forged origin. The HTTP server therefore accepts only the literal loopback
Host for its bound port. It also rejects a present Origin unless that origin is loopback;
non-browser MCP clients omit Origin and still authenticate with the token.
You might be asking why there's a server at all. Assume your agent launches a couple of
subagents. Each of them might also want to read or edit the codebase through Rift, and each gets
its own rift mcp, because MCP's stdio transport has the client launch its server as a
subprocess.
None of those short-lived processes can hold the things that are expensive and shared - the
index, projections, and execution copy - so a long-lived server holds them and every rift mcp
reaches the same one.
And why HTTP rather than a socket? Either way rift mcp has to find the running server, and
HTTP lets an HTTP-capable agent connect to the port without rift mcp.
On startup the server reads rift.toml from the workspace root and accepts the workspace. The
configuration allows you to describe:
- which languages may execute agent-written code
- which embedding model, if any, ranks search
- which exact language entries select LSP processes
- which initial changed paths select each hook
An edit to the file takes effect for later requests once the server accepts it again. An invalid file keeps the workspace unusable until you fix it.
The server stops itself once no request remains active and the
idle_timeout span passes after the last response completes.
rift server stop ends it sooner. The server removes its lock file on the way out, and the next
rift mcp starts a fresh one. Projections,
changesets, and execution copies live under .rift and survive the exit.
rift server status reports which of these states the workspace is in - a serving server with
its port, pid, and version, a stale .rift/server.json, or no server - and changes nothing.
Source discovery
Before Rift analyzes code, source resolvers discover what belongs to the project and what its toolchain already resolved. The built-in project resolver catalogs workspace files.
Language and package-manager resolvers inspect manifests, lockfiles, installed packages, standard libraries, and generated-source registries. They do not download dependencies or change the environment.
Each readable file becomes a SourceUnit in the
source catalog. Its location says whether it belongs to the project, a dependency, the standard
library, or an external source. Its source_kind separately says whether a human authored it or a
tool generated it.
Generated project code and generated dependency code therefore keep both facts. Declarations
invented by a language tool have source_kind: "synthetic" and no source unit.
A resolver publishes a new catalog revision when its inputs change: workspace files, manifests, lockfiles, toolchains, installed packages, or generated-source metadata. Fact providers consume that catalog. A parser such as tree-sitter can describe syntax in a supplied file, but cannot infer which package owns it or which generator produced it.
Providers
Providers analyze cataloged source at different depths and publish immutable Contribution
records in provider-owned symbol spaces. Rift normalizes applicable Contributions into
workspace-local SymbolRecord values, then assembles readable Symbol values for one captured
index revision.
A condition the caller must weigh before relying on the answer - a stale index, an absent
capability - arrives as a typed entry in the result's warnings.
Syntax
Rift ships syntax providers based on tree-sitter by default. They parse readable project, dependency, and generated units into declarations, syntax structure, and parse diagnostics. Project declarations also receive node addresses that edit tools can target.
The server invalidates a changed path's syntax facts and search-index entries.
Semantics
Since v0.0.14 · Rust, TypeScript, PythonA grammar shows how one file is written; it cannot say which other files reference a declaration in it. Resolving that is a language engine's job - an external language server Rift starts for the workspace and speaks LSP to over stdio.
Since v0.0.25Each [languages.<identity>] table selects one named or
inline LSP process for an exact language identity:
rust-analyzer for rust, or
typescript-language-server
for typescript and a separate typescript:tsx entry. Both TypeScript entries can select the
same named process.
An entry may select the embedded ty engine instead of a spawned process: the build links it in, and it serves Python over an in-memory transport through the same session contract a spawned engine gets, so rename, references, and diagnostics run identically over both.
The server starts an engine on the first request for a language it serves and keeps the session
for later requests, because a language server pays for its project load once and answers from it
afterwards. A crashed engine is replaced and the request runs again, within the restart budget
its restart table states; once that budget is
spent the request fails rather than writing half a rename.
The engine never writes. It answers with proposed edits and with diagnostics, and Rift applies what it proposes through the change path every other tool uses, described under Semantic edits.
A language whose entry selects no LSP process keeps everything the syntax tier gives it: reads answer, the direct change tools apply, and only the engine-served tools refuse.
Since v0.0.23Semantic providers publish the facts a grammar cannot resolve - references, definitions, types, and hover information - as separate Contributions. Normalization associates them with syntax Contributions only when exact declaration bindings or explicit equivalence evidence establish one identity. Presentation order selects readable fields after identity resolution. A provider may invalidate dependent facts, such as callers after a signature changes.
Semantic providers also expose dependency, standard-library, and synthetic declarations when their language engine knows them without readable source.
An engine can take shapes other than a language server:
- an embedded library, compiled into the server - ty for Python is one; there is no process to supervise, and the engine version ships with the Rift release. The Python provider supplies ty with the source catalog and tree-sitter facts, and ty maps resolved facts back to the same declarations and concrete source ranges.
- a batch indexer whose output the server reads - a SCIP
index such as the one
rust-analyzer scipemits, the same format Rift itself exports; the index describes exactly one tree revision, so the server caches it per revision
The provider contract is the same in front of every shape, so a language can swap engines - or use one engine for facts and another for edit plans - without changing any tool an agent calls. Syntax remains the fallback wherever semantic analysis has no answer.
Since v0.0.27 · RustThe fallback reaches further than the grammar alone. The binding provider resolves name
binding - which declarations a name in the source can refer to - from the scopes, definitions,
references, and imports each syntax provider extracts in its one parse. It joins files through
the language's module layout and publishes the resolutions as Contributions under the provider
id binding.
Resolution keeps the language's own precedence: a definition in the nearest scope shadows an outer one, an explicit import shadows a wildcard one, and equally ranked candidates all stay in the answer. A reference the syntax facts cannot place stays unresolved, and a language engine's facts join the same records through normalization.
There is no engine to supervise: the provider runs inside the server during the index build,
and the [providers.binding] table bounds every phase of its
work. A breached bound never fails the build - the revision publishes with the syntax facts
alone.
History
The history source resolver reads the workspace's git objects in place, with no checkout or git
subprocess. It publishes a bounded series of source catalogs keyed by revision. The same in-place
access serves revision-addressed reads: get_symbol, search, and nodes accept a rev, and
the server indexes that commit's tree - the same syntax providers, over committed bytes - without
touching the working tree.
Asking for a symbol's history walks that same object store: the server follows first parents from the served revision along the declaration's current path and parses the committed bytes at each revision that changed the file - the same syntax provider, per revision. Comparing adjacent parsed states gives the symbol a timeline: introduced, body changed, signature changed, removed.
The walk is bounded by
providers.history.max_revisions, because its cost scales
with how far back it reaches, and a workspace without version control serves no history facts.
Custom
Custom integrations implement a source resolver, a
fact provider, or both. The contracts stay separate even
when one language engine implements both capabilities. A family with no serving provider surfaces
as a typed entry in warnings, which keeps an empty answer distinct from a complete answer with
no facts.
One integration can connect several cooperating pieces through a provider composition. Like a scikit-learn pipeline, the composition gives each step a stable name and feeds its output into compatible inputs.
The composition API can change the flow before Rift starts:
- insert, replace, or remove a named step
- transform one step's input or output
- branch one output into several providers, then publish each provider's Contributions
- nest one composition inside another
Provider compositions support several flow shapes:
- syntax and semantics consume the same source catalog, then publish separate Contributions
- history maps syntax analysis over source catalogs keyed by Git revision, then aggregates revision-qualified facts
- a custom external step can produce generated source or facts for another provider to analyze
A transform that changes source units or facts becomes a named, revisioned step and publishes new Contributions, so later answers still identify its inputs.
For example, a CSS integration can send templates and configuration to a registered Tailwind CLI, parse its generated CSS with a CSS syntax provider, and publish those facts beside authored CSS. The server runs the command with declared inputs, environment, timeout, and output limits; the composition step decodes its output.
Rift validates the composition's inputs, outputs, merge rules, and cycles before it runs. The server
then owns scheduling, caching, and invalidation; MCP requests do not assemble or modify the
composition. Composition controls data flow and provider selection. It cannot establish
Contribution equivalence or SymbolRecord identity.
The workspace resource assigns each provider a stable
ProviderId and reports its lifecycle state. A
provider derives an immutable Contribution collection from one tree revision, then publishes the
collection atomically.
While a provider catches up after a change, requests may serve its previous revision, and the
result carries a stale_index entry in warnings naming the two tree revision digests. A
provider with no usable revision contributes nothing, and the result's warnings say so.
A read captures one tree revision, one index revision where it searches, and the provider
revisions it uses. Later writes create new revisions; they do not alter the captured result. A
request addresses one page with page_index, and a page past the end returns an empty page
with the true total_pages.
Normalization validates equivalence evidence without using provider order, candidate names, or
provider composition as identity evidence. It retains unresolved and conflicting records instead
of forcing one SymbolId. When Rift assembles a readable Symbol, provider order selects scalar
presentation fields and combines list facts without discarding conflicting Contributions.
Index
Since v0.0.8 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, PythonThe server keeps an immutable index over the project's source. Tree-sitter extracts declarations and syntax nodes from the files the workspace's source policy includes.
The server watches the workspace and validates the published index against it before each current-tree read, so a read serves the tree as it stands and never combines state from two index revisions. The implementation page describes the watcher, the rebuild lane, and the swap that keep this true.
When the published index lags the tree a read captured, the result says so: a stale_index
entry in warnings carries the digests of the tree revision the index covers and the revision
the read captured, so the caller knows the answer may miss the newest writes.
Each rebuild derives lexical units for search.
The rebuild derives one unit per indexed symbol, and chunks from every bounded UTF-8 file without
a NUL byte whose effective language entry or
[search.text].include selects it. Syntax providers enrich files
their effective language entry selects. The units persist in the workspace database at
.rift/db, stamped with the tree revision that published them.
Those units are written before the rebuilt index becomes current, in one transaction with the stamp. A read that captures a tree therefore finds the database already holding it, and a rebuild whose transaction refuses publishes nothing: the previous index stays current, and the read meets the recorded failure rather than an index the database does not hold.
The semantic tier's vectors are not part of that transaction. Embedding one declaration can cost
more than a read is willing to wait, so vectors are refreshed after the index publishes, and a
search whose vectors are still being written ranks lexically and reports the tier's readiness in
warnings.
Both tiers answer under one tree revision: the full-text store reads its stamp and its rows in
one transaction, and the vectors a pass published carry the tree they were described for. A store
that has moved past the tree a request captured returns nothing, and the request captures the
publication the store already answers for. What a caller is told about is a tier that will not
answer at all - one that refused to load, or holds no indexed tree - which raises
lexical_ranking_unavailable.
Search
search is for reads where the exact name isn't known.
It ranks symbol names, signatures, documentation, and file content from the index.
What the index holds comes from normalized Contributions. Syntax providers contribute each declaration's name, signature, and documentation; the lexical tier adds file content - source and plain text alike - so a hit can land in a comment, a configuration key, or a README as well as a declaration. Facts a semantic provider resolved join the same Symbol only after normalization establishes their association, so a richer engine deepens results without changing the tool.
Matching serves two query shapes. An identifier-shaped query - ReadService,
num_workers - matches the declaration names it resembles. A prose query - visible source included - ranks by BM25 term relevance
over names, signatures, documentation, and content together.
The server indexes names both as spelled and split into words on case and separator boundaries,
so get user name reaches a declaration spelled getUserName, and a concept-level search does
not depend on guessing the identifier first.
Semantic ranking
Since v0.0.16 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, PythonThe lexical ranking answers a query that shares a token with the code. A query that shares none - a description of what a declaration does, in the words a bug report would use - reaches nothing lexically, and no amount of ranking repairs that, because the words are not there.
An embedding puts the query and the declaration in one space, so a paraphrase finds code it has no word in common with. It replaces nothing. A caller who quotes a real name - a symbol, a configuration key, a line from a traceback - has handed the lexical ranking an exact token to score, while the embedding maps that name into a space where its near neighbours crowd it.
A declaration is embedded as its own source under its qualified name. The name carries the module the source doesn't state; the source carries the identifiers and documentation an encoder trained on code reads best. The path is left out, because the lexical ranking already covers it.
The two rankings are combined by reciprocal rank rather than by averaging their scores. A
full-text relevance score and a cosine similarity share no scale, so a weighted average of the
two is decided by whichever side happens to spread wider.
fusion_k sets how sharply a top rank counts, and the two
weight keys set each ranking's share.
Preparation runs behind the answers. A search issued before the vectors are in is answered lexically and says so, rather than waiting for a model to load.
Vectors are stored per model and addressed by the text they were embedded from. A declaration that moves or is renamed keeps its text, so it keeps its vector; changing the configured model embeds the workspace again rather than mixing two spaces.
Retrieve symbol
get_symbol retrieves a declaration when its name is
known. The answer carries the declaration's body inline, so reading a function takes one call
rather than a search followed by a file read.
A Symbol carries id when the index established one identity for the declaration; a symbol
the providers could not settle answers without an id, and a retained provider disagreement
lands as a symbol_disagreement entry in the result's warnings.
Asking for history attaches the symbol's committed timeline to the same answer.
Changes
Since v0.0.4 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, PythonBy default, a change lands in your working tree - the same files your editor and your terminal see. Your agent's harness already runs its shell commands and its tests in that checkout; routing Rift's edits somewhere else by default would split the tree the agent edits from the tree it verifies.
What guards a direct write is the address it targets. A node identity carries a witness -
a hash of the bytes it addresses, minted when a listing returned it - and resolution recomputes
the witness before splicing, so an address read before the file changed refuses with a failed
source_unchanged precondition instead of landing in the wrong place. The server serializes
change application per tree, so two agents editing at once collide as one clean refusal rather
than as interleaved bytes.
Declaration tools resolve provider-published declaration and attachment ranges from the addressed Symbol. One planner therefore serves Rust, JavaScript, TypeScript, TSX, Markdown, JSON, YAML, and TOML; adding another syntax provider does not add another write path.
A change through MCP - replace_symbol,
replace_node,
patch, any of them - is resolved before Rift writes
anything. Empty and byte-equal edit sets refuse as source_unchanged; applied always means at
least one path changed bytes. Duplicate paths refuse before publication, and every multi-file
transaction writes in project-path order. If it produced effective edits, the change lands and
comes back carrying everything Rift learned about it.
The server selects the hooks a change runs from its initially changed paths, through each hook's
include and exclude. It fixes the complete selection before any hook runs, so a transform's
own writes cannot pull a later hook into the run.
Transform hooks run after direct edits, in configured order. A passing transform may keep source
writes inside its declared writes scope. Rift restores a failed transform, a write outside that
scope, or any source permission change. It then computes the changed files and change id from
the difference between original tree and final transformed tree. A transform that restores every
direct difference returns unchanged, not applied.
Validation hooks run after transforms and may not retain source writes. Their failures attach warnings or errors at configured severity, while successful validation claims attach guarantees. Neither outcome rolls back final source: validation reports what landed, like an editor check after save.
A validation command may replace build artifacts outside visible source even when writes is
none. Rift therefore ends existing language-engine sessions after validation and starts fresh
sessions for post-change diagnostics, so an engine cannot retain paths to removed artifacts.
What rides on the result is the part that changes agent behavior. Each applied change carries:
- its diagnostics - the syntax provider re-parses what changed, and the language engine serving a changed path reports what it makes of the final bytes
- the verdicts of the selected hooks, run in transform-then-validation order
- its
Advisorylist: concerns a provider or hook attached to exactly this change
An advisory the emitter could verify itself arrives already checked, and an open warning carries the concrete instruction that settles it - the check to run before trusting the change. A checked advisory never carries warning severity, because a warning marks a check still to run.
Projections
PlannedSometimes you don't want the agent writing into your files at all - a refactor you want to
review whole, or several agents working the same workspace without treading on each other. A
projection is the opt-in for that:
projection_create gives the agent a
pinned copy of the workspace, every change tool takes the projection's name as its
target, and nothing reaches your files until publish.
Creating a projection names it. The call carries a name - a valid directory name, such as
my-feature-one - and a short description of the task the projection holds. The name is how
later calls address the projection and where its directory appears; the description is what lets
projection_list read as a task list, which is how
abandoned work gets found.
A fresh projection is a pinned, byte-identical snapshot of the workspace. The server records a
tree digest and a manifest of every entry, and Rift FS serves that captured revision
as the projection's directory. A later git pull changes the workspace but leaves the
projection at its captured revision.
Serving is lazy - content materializes on first read - so creating a projection costs the same for a ten-file tree and a monorepo. Symlinks are served and never followed. On a host where Rift FS cannot mount, the server copies the tree into a plain directory instead: the same contract, paid at creation.
The projection is a real directory, at a predictable place below the workspace:
~/projects/charming-aurora/.rift/projections/my-feature-oneThat's deliberate: plenty of what a developer runs can't be handed a protocol - cargo test,
prettier, a linter your team wrote in 2019. They take a directory, so the projection is one,
and the projection resource hands out its path.
A write into the directory lands in the projection alone: Rift FS detaches the written path into
the projection's own store, and the workspace file is never touched. The path remains until
projection_remove deletes the directory -
without waiting for whatever still runs inside it, because a lock there would let one abandoned
cargo watch block cleanup forever.
Rift change tools record their edits as they apply. An ordinary process can bypass those tools by writing into the projection directory - and because Rift FS is that directory, the server observes each bypassing write as it lands.
The server groups the observed delta into a
ChangeSummary whose origin is filesystem,
records the exact changed paths, reruns providers and hooks, and attaches an external
confirmation. A copy-backed projection reaches the same summary by comparison: the server checks
the directory against its manifest before each projection read and publication. Binary files and
new symlinks stay publishable even though their bytes have no portable
Edit representation; the change identifies them by path alone.
That reconciliation can discover a change during publish. In that case publication returns
the newly minted change id in unaccepted and writes nothing. The caller reads
rift://changes, reviews the imported delta, and
retries with that id in accept.
Reconciliation follows project visibility. A tracked or unignored file enters the imported
change, while ignored output outside the projection manifest - target/ in a typical Rust
workspace - stays outside the changeset.
In a projection, an open warning advisory mints a
ConfirmationRequirement on its
change. The change remains inspectable in the projection, but publish refuses until the call
accepts it by change id. Deletions and hooks that fail or do not finish produce the same gate,
because publication is the step that writes the workspace.
A change to rift.toml made in a projection carries a configuration confirmation into
publish, because publishing it changes which hooks and limits the server runs with.
publish also compares each changed path's workspace entry with the manifest entry from which
that projection path started. A different entry returns in conflicts, and the server writes
nothing. Publication is serialized workspace-wide; on success Rift writes every changed path, rebuilds
the projection from the resulting workspace tree, advances its base revision, and empties its
changeset and read set.
A projection's base does not have to be the workspace.
projection_create can pin a child from another
projection's current state: an orchestrator holds one projection for a large task, and each
subagent branches its own from it. The child publishes into its parent through the same gate -
per-path baselines, conflicts as refusals - serialized per target tree, and every chain ends at
the workspace.
There is no direct merge between sibling projections. Two siblings meet through their shared parent: each publishes into it, and the second publish sees the first one's writes as changed baselines and reports exactly the paths that collided.
A projection read can depend on a file the change never touches. When get_symbol returns a
declaration, search returns a hit, or nodes reads a file, the server records that file's path
and SHA-256 in the projection's read set. The set holds at most 4,096 files and 512 KiB of
path-and-digest data; a read that would cross either bound fails with limit_exceeded before
returning its result.
During publish, the server compares each read dependency that is not also a changed path with
the current workspace file. A mismatch returns a
DependencyConflict in
dependency_conflicts, and the server writes nothing.
The caller can reread and revise the projection, or copy that exact conflict into
accept_dependencies. The server recomputes the current digest while holding the workspace
publication lock, so another workspace edit invalidates the copied acceptance.
The read set covers files returned through projection-scoped Rift reads. These reads stay outside it:
- an empty
searchresult names no file to record - a process reading the projection directory bypasses Rift entirely
rift://fsreads the workspace tree, so it adds no projection dependencies
When the task depends on absence or on bytes obtained outside Rift, take a fresh read.
projection_restore goes the other way,
refreshing selected changed paths from the current workspace and dropping whole changes that
touched them. The refreshed workspace entry becomes that path's new conflict baseline, while
other paths retain their original baselines.
Projections outlive the agent that made them. After a server restart,
projection_list returns the same pinned base.
projection_remove deletes the directory and any unpublished work.
Semantic edits
Since v0.0.14 · Rust, TypeScriptRenaming a declaration is one decision followed by an edit in every file that mentions it. The direct change tools address one declaration per call, so that decision costs the agent a read and a write per site, and finding every site is reference resolution - a semantic fact.
A language server answers from the moment it initializes, and answers wrongly until it has
loaded the project: rust-analyzer refuses a rename with No references found at position for a
declaration it has not indexed yet. It announces that load over LSP's $/progress, and while
the work is outstanding the server discards what came back and asks the engine again, under that
engine's retry table. Every engine refusal receives
that same bounded schedule: a refusal can be the engine's final verdict, or an early answer from
an engine that has not loaded the declaration yet. If every attempt refuses, Rift returns the
latest engine words. An engine that died mid-request is replaced under restart.
Progress does not say which request the announced work settles. An engine that answers nothing
looks exactly like one that has finished and has nothing to report:
workspace/willRenameFiles comes back with no edit, textDocument/references with no location,
and textDocument/diagnostic with no item. Rift therefore asks every empty semantic answer through
the configured retry schedule, whether progress completed or never appeared. The document remains
open across those attempts. Each classified workspace change invalidates earlier readiness. The
retry table, not one remembered answer from an earlier operation, bounds every check.
When a move or reference check spends that table, Rift keeps the final empty answer. A move carries
its operation-specific warning. A reference check is clean when readiness is confirmed; an
unconfirmed final answer warns or refuses. A diagnostic pull has a stronger rule: a nonempty full
report can settle after announced work ends, while an empty full report must repeat at the end of
the retry schedule. A
workspace/diagnostic/refresh request invalidates earlier pull evidence. An answer that still
changes at the bound is unready, never proof of clean bytes.
When the whole budget is spent while announced work remains outstanding, a rename or a move fails
temporarily_unavailable with the tree untouched, so the caller can send the same request again
once the engine has loaded.
Rename a declaration
rename_symbol hands the decision to the language
engine. The server resolves the address to the declaration's position, asks the engine to rename
it there, and receives the affected files with the byte ranges to replace. Applying them stays
the server's job.
A language server normally hands its edits straight to the editor that hosts it, and whether
every file got its share is the editor's problem. Rift treats the answer as a proposal: it
compiles into the same Edit set every change tool produces -
one shared input state, no overlaps, atomic across files - and the server re-proves each file's
bytes before writing, so a proposal computed against bytes that have since moved refuses with a
failed source_unchanged precondition.
The engine works against the workspace directory, because a language server takes a directory,
like cargo test and the linter before it. It reads the tree's bytes as the change tools write
them, so its analysis tracks the rename as it lands.
An engine can miss sites - a name built at runtime, an occurrence in a doc comment or a template
no resolver parses. After the rewrites land, the server sweeps the changed tree for surviving
word-boundary occurrences of the old name, and each finding rides the change summary as a warning
under rift.rename.survivor.
A language whose entry selects no LSP process refuses the call as unsupported, naming the
segment nothing serves. The engine's own refusal comes back the same
way and keeps the engine's words - rust-analyzer's cannot rename to a keyword reaches the
caller as unmet_precondition - and the tree stays as it was.
Move a file
move_file moves one visible file and asks the engine for
the reference updates through LSP's workspace/willRenameFiles. Those rewrites and the move land
in one atomic change, so a moved module and the imports naming it never disagree on disk.
An engine that does not advertise that request still gets the file moved, and so does a workspace
with no engine for the language or an engine whose filters skip the file. The move applies, and
the result carries a warning under rift.move.references_not_updated saying references were not
updated. Refusing the move instead would trade a gap the caller can see and repair for an error
it cannot act on.
The same warning names an engine that spent its retry schedule proposing nothing while it had announced no work of its own. A loaded engine that proposes nothing is telling you there is nothing to update, and its move carries no warning; one that has never said what it is doing is not, and the warning names it so the caller can check the imports itself.
Diagnostics on a change
After any change applies - a rename, a move, a patch - the
server groups changed paths by engine. Each engine first receives one ordered
workspace/didChangeWatchedFiles notification with added, modified, and removed classifications.
Rift then opens each surviving document with final bytes, pulls diagnostics through the bounded
retry schedule, closes it once, and maps findings onto the change summary beside the syntax
provider's own.
The pull reads landed bytes, so what comes back is the engine's reading of the change rather than of the tree before it. One batch lets a parent declaration and its new module arrive together, and lets a removed diagnostic disappear before any post-change pull.
A pull the engine answers while it is still loading the project comes back empty, and so does a
pull over code that is genuinely clean. The server tells the two apart from the engine's own
$/progress traffic and repeated report evidence: window/workDoneProgress/create and the
matching progress token mark work outstanding before the begin notification can race a request.
While work remains, its answer is provisional and the server pulls again under that engine's
retry table. Once work ends, a nonempty full report
settles. An empty full report still requires equal reports at the end of that schedule, because
progress does not bind its completed work to this document.
An engine that advertises no pull contributes nothing and says nothing about it, because a capability the engine never advertised is not a failure. typescript-language-server is that case: it publishes diagnostics instead of serving pulls, so an applied change carries its silence.
An engine that fails the pull degrades to one warning naming it under rift.engine.failed. One
that never reaches settled evidence inside its retry budget degrades under rift.engine.unready,
with changed paths grouped by engine and language. The change has already landed by then, so
nothing at this step can refuse it - and neither warning is replaced by an empty finding list,
which would tell the caller the file is clean.
Edit plans
PlannedAn edit plan is the proposal before any of it is applied, held for the caller to look at: each affected site as a concrete byte range with its replacement. A plan targets a tree the way every change does - the workspace by default, or a projection the call names. Projections stay the agent's decision, so a quick rename lands directly while a refactor worth reviewing whole runs in a projection the agent created for it.
In a projection each surviving occurrence becomes an open warning
Advisory, which mints a
ConfirmationRequirement on the change, so
publish refuses the rename until someone has looked at what
the engine left behind.
A batch indexer needs a tree that holds still: a projection is already pinned, and for the workspace target the server pins an internal tree through Rift FS - that tree feeds the indexer and is never a projection the agent has to manage.
The engine's whole job is the plan. Everything after it - witnessing, atomic application, the publish gate - is the write path the server already owns, so swapping a live language server for a cached SCIP index or the reverse is a provider change invisible above the provider contract. A custom integration contributes a plan source the way it contributes facts: implement the fact provider contract, and the composition routes the language to it.
Execution
Plannedexecute evaluates a block of agent-written code in
the targeted tree's execution copy - a directory under .rift/exec the server keeps beside
the tree, one per target. The copy persists between calls: before each evaluation the server
refreshes its visible files to match the targeted tree, and everything else - a virtualenv the
last run installed, a build cache - stays as the previous run left it. A verification loop needs
that persistence, because pip install followed by a test run is two calls, and a copy thrown
away between them could never hold the install.
Writes addressed inside the execution directory stay in the copy. The next refresh replaces a visible file with the targeted tree's version; the server never synchronizes copy writes back.
The execution copy does not sandbox code. The runtime uses the server's OS permissions, so an absolute path can reach outside the copy, including the workspace.
rift.toml decides which languages may execute.
Rift FS
PlannedProjection directories, execution copies, and the pinned trees batch engines index share one requirement: a real directory that is not the workspace. Rift FS is the filesystem layer that serves them. To every program it is an ordinary directory; no tool names it and nothing on the protocol surface addresses it - an agent meets Rift FS only as a path that works.
Each served tree holds one contract:
- The tree serves its pinned base - the revision captured at creation. Later workspace motion is invisible inside it.
- The first write to a path detaches that path into the tree's own store. A write into a served tree never touches the workspace.
- A deletion holds in that tree alone.
.riftis never visible inside a served tree, so a tree cannot reach its own machinery.- Removal is immediate. A process still running inside gets I/O errors; the tree is never half-deleted under it.
Every read and write of a served tree passes through the server, and that observation is what Rift FS buys beyond a cheap copy. The workspace needs a native watcher because other programs write it directly; a served tree needs none - the server is its write path. An observed write advances the tree's change counter, invalidates facts for exactly the changed paths, and joins the same serialized rebuild lane the workspace index uses.
Projections scale by staying apart. Each one is its own targeted tree with its own store,
changeset, read set, and fact revisions, so a write in one invalidates nothing in another and
nothing in the workspace index. The trees meet only at
publish, which stays serialized workspace-wide.
Observation also covers the one thing a served tree cannot do: deliver filesystem-watcher events. The server notifies a live language server rooted at a projection instead, since it knows every write - the plans it applied and each external one.