rift

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.11

When 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, Python

A 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.25

Each [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.23

Semantic 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 scip emits, 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 · Rust

The 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.

Since v0.0.13 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, Python

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, Python

The 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.

Since v0.0.9 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, Python

Each rebuild derives lexical units for search.

Since v0.0.25

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 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, Python

The 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, Python

By 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.

Since v0.0.25

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 Advisory list: 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

Planned

Sometimes 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-one

That'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 search result names no file to record
  • a process reading the projection directory bypasses Rift entirely
  • rift://fs reads 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, TypeScript

Renaming 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

Planned

An 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

Planned

execute 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

Planned

Projection 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.
  • .rift is 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.