MCP
rift mcp serves MCP over stdio for harnesses that launch servers as subprocesses. Stdout carries
MCP frames; the process writes tracing events to stderr.
Requests do not open connection-scoped sessions. Each request carries every address and witness it uses; the server holds no per-caller state between calls.
A tool result rides MCP
structuredContent,
typed by the tool's declared output schema, with the
same JSON mirrored as a text block for harnesses that ignore it. A refusal is such a result -
ChangeResult saying refused, never isError -
while ErrorData travels as the
JSON-RPC error
object's data, under code -32000. The exported schema at
mcp.json is the authoritative served shape.
The server serializes change application per targeted tree: two concurrent changes against one tree apply one after the other, and the later one meets its preconditions against the earlier one's result. Reads run concurrently.
Reads
Callers read code through these tools:
| Tool | Result |
|---|---|
get_symbol | declarations and their source by known name |
search | ranked files and symbols across the workspace tree |
nodes | the syntax nodes covering one position, with witnessed addresses |
get_symbol and search page by page_index. The request names the zero-based page under its
limit, and the result's pagination carries the served page_index and the true
total_pages. The server retains no capture between pages: each page is computed from the
request, and a page_index past the last page returns an empty page whose pagination still
carries the true total_pages.
Every read result also carries warnings. A stale_index warning reports an answer computed
from an index that lags the tree the read captured, and names the two tree revision digests that
disagree.
Three more warnings cover the search ranking tiers:
semantic_index_preparing- the semantic ranking is still being built. A query sharing no token with the code it describes reaches nothing until every declaration carries a vector;preparedandtotalstate how far that has got.semantic_ranking_unavailable- the semantic ranking will not answer for the life of this server: the model weights could not be acquired, or the model failed to load.lexical_ranking_unavailable- the full-text tier failed to load or holds no indexed tree. Ranking falls back to identifier matching, so a query phrased as prose finds nothing; the tier is read under the answer's own tree revision, so one holding another tree is recaptured rather than warned about, a case a caller cannot otherwise tell apart from a tier that searched and found nothing.
A symbol_disagreement warning names a symbol whose selected presentation and at least one
provider's own facts disagree.
Every read carries an optional rev, and reads the workspace tree when it is absent.
Revision-addressed reads
Since v0.0.7 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, PythonEvery read also accepts rev - a branch, tag, or commit id, spelled the way git spells it:
main, v0.0.6, dd0a482. The server resolves the revision in place from the workspace's
git objects, with no checkout, and indexes that commit's tree under the same
[source] policy and bounds as the working-tree scan. A revision
read answers from committed bytes, so uncommitted working-tree edits never appear in it.
This get_symbol request reads a declaration as it stands on main, whatever the working tree
currently holds:
{ "name": "ReadService", "rev": "main" }A revision search refuses force_include: reaching index-excluded files is a walk of the
working tree, which a revision does not have.
A workspace with no git repository refuses a revision read and names the remedy:
Error:
{
"code": "capability_unavailable",
"message": "no configured provider serves this request: workspace /Users/alice/projects/charming-aurora, requires a git repository - run `git init`, or omit `rev` to read the current tree; adjust the request to a served capability, or configure a provider that serves it",
"retry": "operator_action",
"phase": "read"
}An unknown revision refuses with resource_not_found, and
[providers.history] owns the capability: enabled = false
refuses every revision read.
get_symbol
Since v0.0.4Finds declarations and their source by exact symbol name. Each hit carries the declaration and its source excerpt unless `include` omits `source`. `include: ["history"]` adds each hit's version-control timeline, walked from the served revision. `rev` serves the lookup from a version-control revision instead of the current tree. Use `search` when the name is not exactly known.
include selects the optional fields a hit carries: source - the node identity and
declaration excerpt - and history - the version-control timeline. An omitted include
defaults to ["source"]; an explicit empty list carries neither.
include: ["history"] attaches the symbol's version-control timeline as a
SymbolHistory. The server follows first parents
from the served revision along the declaration's current path, bounded by the max_revisions
key in [providers.history], and classifies what each
touching revision did: introduced, body_changed, signature_changed, removed, or
decorators_changed. A revision whose source cannot be parsed, or where the symbol is absent,
contributes no version.
search
Since v0.0.6Searches indexed declarations and source lines by lexical `query`, merged with full-text matches from included `[search.text]` files and declaration bodies, and by a bounded relationship `traversal` from one seed symbol. `rev` searches a version-control revision instead of the current tree, and never combines with `traversal`. Use `get_symbol` when the declaration name is known. For a current-tree search, the published workspace is resolved exactly once and threaded through both the search index's revision check and the executed `ReadService::search` call: a concurrent rebuild between two separate resolutions could otherwise validate ranked units against one snapshot and merge them into results computed from another.
search takes a lexical query, narrowed or extended by a paths selector. paths.include and
paths.exclude glob the project-relative path - the same glob engine backs the workspace's
[source] policy - narrowing the indexed candidates before
scoring.
paths.force_include reaches past both: a file [source] or .gitignore excluded from the
index is parsed on demand and searched alongside the indexed hits, up to 256 matched files per
request. A request whose paths.force_include matches more than that refuses with
limit_exceeded rather than searching a silently truncated set.
The top-level include field is a separate selector: it requests extra payload per hit instead
of narrowing which files are searched. source attaches the excerpt around the hit; score
attaches the fused ranking value used to order the page, comparable only within this one answer.
This request narrows the search to the tests tree, then reaches one file the workspace's
[source] policy excludes from the index:
{
"query": "test_fit",
"paths": {
"include": ["tests/**"],
"force_include": ["tests/legacy/test_solver.py"]
}
}Full-text search
Since v0.0.9 · Rust, JavaScript, TypeScript, Markdown, JSON, YAML, TOML, Pythonquery matches declaration names, file contents, and declaration sources through a full-text
index. A multi-word query matches a unit that carries any one of its terms anywhere in its
content, and a unit carrying more of the query's terms ranks above one carrying fewer.
A symbol whose only match sits in its declaration, not its name, still returns as a symbol
hit. The ranked lane placed it, so the hit is tagged matched_by: ["ranked"], not
["content"] - a claim only the identifier or line matcher can make against literal bytes:
Request:
{ "query": "literal minted" }Search hit:
{
"hit": {
"target": "symbol",
"symbol": {
"id": "rift://symbol/rust/crates/rift-core/src/identity.rs/encode_path",
"language": "rust",
"name": "encode_path",
"kind": "function",
"facets": ["value", "callable", "public"],
"visibility": "pub"
}
},
"matched_by": ["ranked"],
"range": { "start": 1496, "end": 1925 },
"line": 43,
"path": "crates/rift-core/src/identity.rs"
}Every visible bounded UTF-8 file without a NUL byte joins the same index when its effective
language entry or [search.text].include selects it. A hit on one
of those files returns as a file hit, tagged
matched_by: ["ranked"] the same way:
Request:
{ "query": "install checksum", "target": "file" }Search hit:
{
"hit": {
"target": "file",
"size": 3546
},
"matched_by": ["ranked"],
"range": { "start": 820, "end": 920 },
"line": 35,
"path": "README.md"
}A path carrying no extension - justfile, Dockerfile - is selected by the same patterns and
returns the same way, because the selection reads the whole project-relative path. A file hit
carries languages only where a syntax provider claims the path; the ranked lane can also reach
a file no provider claims, as this one does.
The hit above carries no source: a request pays that lookup only when include names it.
include: ["source"] attaches the excerpt as source on every hit; an omitted include returns
the hit's symbol or file, path, range, and line alone, as both hits above do.
A hit's score follows the same rule: absent unless include names it. include: ["score"]
attaches the fused ranking value that ordered the page, comparable only within this one
answer.
Traversal
Since v0.0.29 · Rusttraversal walks the relationship graph [providers.binding] builds: a bounded search from
one seed, following direction and narrowed to facets, up to depth hops. seed and the
optional to are declaration SymbolId values, the same
identity a get_symbol or search hit already carries.
traversal stands alone as a pure graph walk or rides beside query; a symbol both lanes
reach keeps its lexical score and gains the walk's traversal_path, and its matched_by
carries both entries.
Three shapes cover most questions:
- Neighbors -
seedalone,directionanddepthat their defaults (outgoing, 1): what this symbol calls, imports, or otherwise references directly. - Impact -
direction: "incoming": what would change if this symbol did, read the other way round. - Path -
toset: whether, and by which shortest route, the walk reaches one named symbol.
Each reached symbol becomes a hit tagged matched_by: ["relationship"], carrying distance -
the path length - and traversal_path, the GraphHop list
from seed to the hit. Every GraphHop carries the
Relationship it followed and the direction it
followed it in; seed itself is never a hit.
This impact query asks what calls resolve_traversal_seed:
Request:
{
"traversal": {
"seed": "rift://symbol/rust/crates/rift-server/src/search.rs/resolve_traversal_seed",
"direction": "incoming"
}
}Search hit:
{
"hit": {
"target": "symbol",
"symbol": {
"id": "rift://symbol/rust/crates/rift-server/src/search.rs/collect_traversal_hits",
"language": "rust",
"name": "collect_traversal_hits",
"kind": "function",
"facets": ["value", "callable"],
"visibility": "private"
}
},
"matched_by": ["relationship"],
"range": { "start": 29305, "end": 31111 },
"line": 779,
"path": "crates/rift-server/src/search.rs",
"traversal_path": [
{
"relationship": {
"from": "rift://symbol/rust/crates/rift-server/src/search.rs/collect_traversal_hits",
"kind": "calls",
"facets": ["calls"],
"to": "rift://symbol/rust/crates/rift-server/src/search.rs/resolve_traversal_seed",
"derivation": "resolution"
},
"direction": "incoming"
}
],
"distance": 1
}facets narrows the walk to RelationshipFacet
values such as calls, references, and imports; an empty or omitted list follows every
facet. target: "file" never carries a walked hit, since a traversal only reaches symbols.
A workspace with the binding provider disabled, and no relationship data left from an earlier
build, refuses capability_unavailable naming relationship traversal (providers.binding disabled): the graph this walk needs was never built. traversal never combines with rev -
the relationship graph serves the current tree alone - and refuses capability_unavailable
the same way. A seed that resolves to neither a graph node nor a lexical declaration refuses
resource_not_found; one that resolves but carries no edges answers an empty result set, since
an isolated declaration is a legitimate answer.
A walk the server stops at its node bound attaches a traversal_truncated entry to
warnings, naming how many symbols it visited: hits reachable beyond the bound are missing,
and a tighter facets list, a smaller depth, or a less-connected seed fits the walk under it.
nodes
Since v0.0.4Lists the syntax nodes covering one UTF-8 byte position in one file, outermost first. Each identity carries a witness, so an address taken from this listing refuses cleanly once the file's bytes drift. `rev` lists the nodes as of a version-control revision instead of the current tree. A visible path no syntax provider parses refuses `capability_unavailable`, naming the extension.
Each identity carries the witness a later
replace_node verifies.
A visible path no syntax provider parses - justfile, .gitignore - refuses
capability_unavailable, naming the extension, or stating that the path carries none.
Resources
Server logs
Since v0.0.22rift://logs reads the server's own diagnostics back, newest first. Events admitted by the
[logs] capture filter enter a bounded queue and one task writes them to the workspace database,
so an agent that met a refusal can ask what the server was doing without an operator reading its
terminal. When the queue fills, the task drops later events and records the dropped count:
| Resource | Content |
|---|---|
rift://logs | every recorded diagnostic, newest first |
rift://logs/level/{level} | one severity: trace, debug, info, warn, or error |
rift://logs/component/{component} | one component's records, as its spans label them |
The answer is a JSON object carrying records and record_count. Each record names its
identity (the store's own ascending number), recorded_at_ms, level, target,
component, operation, message, and the remaining fields as an object. A span that
closed carries its elapsed_ms, which is how long the work it wrapped took.
The read never waits on workspace readiness. A request that refuses because the index will not
settle is exactly the request whose reason these records hold, so the log read answers while
every workspace read is still refusing. A store that could not be opened answers with an empty
set and an unavailable reason rather than a refusal.
[logs] page_records bounds one read; retention_records bounds the store. Both are
workspace configuration.
Workspace
Since v0.0.25rift://workspace reads accepted configuration together with one captured source catalog page.
The answer carries:
configuration_revision- an eight-character digest of the acceptedrift.tomlbyteslanguages- effective identities in order, with enabled state, include and exclude patterns, syntax availability, execution permission, and selected LSP process statehooks- configured hook identities, kinds, and effective path selectionsource- captured project paths, content digests, and matched language identitiespagination- the zero-based page and total page count
The bare URI selects page zero. rift://workspace?page_index=1 selects another page. Each page
holds at most 1000 source units; language and hook summaries repeat so every page can be read
alone. A page past the end returns an empty source list with the true page count.
Map
Since v0.0.29rift://map reads a workspace orientation snapshot: a starting picture for an agent new to the
codebase, computed once when the index publishes and served from cache after that, so a read
costs a lookup. The answer carries:
revision- an eight-characterDigestof the indexed tree the map was computed fromlanguages- per-language file and symbol counts, sorted by language spellingmodules- the directory tree indexed files sit under, each entry's file and symbol counts inclusive of its descendants, folded three directories deephubs- the most-referenced symbols, ranked by reference count descendingentry_points- symbols where execution startsdocs- markdown files, in path orderpagination- always page zero of one, the whole map on one page
hubs and entry_points need facts only the Rust provider currently supplies. hubs ranks
SymbolIds by the reference counts the
binding provider resolves; entry_points lists symbols
carrying the entrypoint SymbolFacet - a file-scope
fn main, not one nested in a module, an impl block, or another function. Both stay empty for
a workspace with no such facts.
Request: rift://map
Response:
{
"revision": "3f9a1c2e",
"languages": [
{ "language": "rust", "files": 191, "symbols": 3204 }
],
"modules": [
{
"path": "crates",
"files": 191,
"symbols": 3204,
"children": [
{
"path": "crates/rift-server",
"files": 16,
"symbols": 418
}
]
}
],
"hubs": [
{
"symbol": "rift://symbol/rust/crates/rift-server/src/read.rs/ReadService",
"kind": "struct",
"references": 87
}
],
"entry_points": ["rift://symbol/rust/crates/rift/src/main.rs/main"],
"docs": ["README.md"],
"pagination": { "page_index": 0, "total_pages": 1 }
}Planned resources
PlannedResources expose workspace state to consumers such as a UI. They carry data shaped for paging:
| Resource | Content |
|---|---|
rift://projection/{id} | one projection's path on disk and its state |
rift://changes{?projection,page_index} | one changeset, each change with its evidence |
rift://fs{/path}{?start,length,page_index} | a directory's entries, or a bounded range of file bytes |
rift://fs serves file bytes and directory listings to consumers without host
filesystem tools.
projection_create materializes the complete pinned directory.
Reading rift://projection/{id} returns its stable path and base revision. Before returning
projection state or changes, the server reconciles direct filesystem writes against the
projection manifest, so a shell write appears in the same changeset as a server-applied change.
A paged resource answer carries pagination the same way the read tools do. A file-range read
continues by byte offset; the payload's digest detects a file change and requires a restart
from offset zero.
Changes
replace_symbol, insert_symbol, replace_node, and insert_node take body, and patch
takes patch, as a BodySource: inline text, or an
object naming file. file is an absolute path the server reads directly - unlike every other
path in the protocol, which is project-relative - so a caller that does not run on the same
machine as the server cannot use this form. Both forms are bounded by the field that embeds them:
1 MiB for body, 4 MiB for patch.
Every write tool resolves its target through the workspace's
[source] policy before it stages a byte, move_file's
destination included, and refuses unsupported, naming the policy, when the target lies outside
it. A target that is a symlink publishes beside its resolved file rather than replacing the link,
once that file stays inside the workspace and passes the same policy; the result then carries a
warning naming both paths. A resolved target outside the workspace, a broken link, or a link
cycle refuses the same way instead, and a deletion removes the addressed link itself without
following it.
replace_symbol
Since v0.0.6Replaces one declaration addressed by symbol. The whole declaration includes its attached outer attributes and doc comments. The parser derives the span, so the caller supplies no offsets; a refusal names the failed precondition and leaves the workspace untouched. The body is spliced in verbatim at the declaration's own start byte: its first line inherits the declaration's column, and every later line carries whatever indentation it is written with.
The span the parser derives reaches back over the declaration's attached outer attributes and doc comments -
#[derive(Debug)] above a struct, the /// lines above a function - so a replacement body also
replaces that documentation. A blank line between a comment and the declaration detaches it,
matching rustdoc's own attachment rule.
insert_symbol
Since v0.0.6Inserts a new declaration beside an anchor symbol, or content at a file target. Anchored insertions land beside the anchor's whole declaration, its attached outer attributes and doc comments included. A file target lands the body verbatim at the file's start or end, creating it first when `create_missing` is set and it is missing. A refusal names the failed precondition and leaves the workspace untouched. A body inserted `before` its anchor is spliced in at the anchor's start byte and its first line inherits the anchor's column; a body inserted `after` its anchor, or at a file target either side, always starts a fresh line at column zero.
Inserting before an anchor lands above its attached doc comments and attributes rather than
splicing between them and the declaration; the anchor's span is the same whole-declaration span
replace_symbol uses.
create_missing creates a missing file target first, parent directories included; without
it, resolution refuses and names the missing target.
replace_node
Since v0.0.4Replaces one syntax node through a witnessed address from `nodes`. The server recomputes the witness before writing and refuses when the bytes drifted, so a stale address never splices into moved code.
replace_node reaches below a declaration through a witnessed node address from
nodes.
insert_node
Since v0.0.20Inserts new content beside a syntax node addressed through a witnessed address from `nodes`. The server recomputes the witness before writing and refuses when the bytes drifted, the same check `replace_node` runs. Unlike `insert_symbol`, which separates a new declaration from its anchor with a blank line and preserves the anchor's indentation, `body` lands verbatim at the node's own boundary with no separator of its own: a node is not a declaration, so the caller supplies whatever spacing and indentation the inserted bytes need.
insert_node reaches below a declaration the same way, addressing an anchor node rather than a
symbol. body lands verbatim at the node's own boundary, before or after it: a node is not a
declaration, so none of insert_symbol's blank-line separator or column preservation applies,
and the caller supplies whatever spacing the inserted bytes need.
patch
Since v0.0.6Applies unified-diff hunks to workspace files atomically. The target is any file the workspace's `[source]` policy makes visible, parsed or not. Hunk context guards the change: a header's line numbers are hints and its line counts are read from the hunk's own body, as with `git apply`. A `/dev/null` header creates or deletes the file. A body that is not a unified diff, such as an `*** Begin Patch` envelope, is refused naming the form to send. The result names each file the change wrote with its size and line counts.
patch applies a unified diff following git apply
semantics: a hunk's context and deleted lines locate it, and its @@ header's line numbers are
only a hint. Resolution starts each hunk's search at its header position, corrected by the drift
already found from earlier hunks in the same file, then widens outward - nearer positions first,
the earlier position breaking a tie.
The target is any file the workspace's [source] policy makes
visible, parsed or not: justfile and docs/content/docs/configuration.mdx both resolve, the
second through the same text index search reads. A path the policy excludes
refuses unsupported, naming the policy; a directory occupying the target refuses
unmet_precondition naming target_is_file.
A header's line counts are a hint on the same footing. The server derives each hunk's counts
from the hunk's own body before parsing it, the way git apply derives them, so a miscounted
header applies and only a body whose context resolves nowhere refuses.
A --- /dev/null header creates the named file; a +++ /dev/null header deletes it. A hunk that
renames or copies a file refuses as unsupported, because patch carries no rename or copy
support.
Matching compares line content, not its ending: an LF-formatted diff locates against CRLF source, and the reverse. A located hunk's context keeps the exact bytes already in the source, and a line the hunk inserts takes the ending already prevailing at that position, so no hunk introduces a foreign ending elsewhere in the file.
The server refuses a body that is not a unified diff, such as an *** Begin Patch envelope, as
invalid_request naming the --- a/ and +++ b/ form to send.
A hunk that resolves nowhere refuses with unmet_precondition, naming the failed hunk's
ordinal, the file line the search reached, and the context it expected against what stood there.
A real mismatch names each side's content and line ending, so two lines that print alike are
never reported as the same.
rename_symbol
Since v0.0.14Renames one declaration addressed by symbol through the configured language engine. The engine proposes the edits; the server verifies each one against the tree and writes them atomically, then reports surviving occurrences of the old name as warning findings. Refused as `unsupported` when no engine serves the declaration's language; a refusal leaves the workspace untouched.
The language engine configured for the symbol's language
proposes the edit set; the server compiles that proposal into the same witnessed rewrites every
change tool produces. A language with no selected lsp
refuses as unsupported, and an engine that declines the new name refuses as
unmet_precondition naming engine_proposed_edits, carrying the engine's own words. After the
change applies, the server sweeps the tree for surviving occurrences of the old name and reports
each one as a warning.
move_file
Since v0.0.14Moves one visible file to a new project path. When the configured language engine advertises will-rename requests for the file, its reference updates land in the same atomic change; without an engine or the capability the move still lands and the result carries a warning that references were not updated. A refusal names the failed precondition and leaves the workspace untouched.
Any visible regular file moves, including one no syntax provider claims; a directory in its
place refuses unmet_precondition naming target_is_file. An engine advertising
workspace/willRenameFiles proposes the reference updates, and they land with the move as one
change. Without such an engine, or without a provider that claims the file's language, the move
still applies and the result carries a rift.move.references_not_updated warning.
remove_symbol
Since v0.0.19Removes one declaration addressed by symbol. The whole declaration, its attached outer attributes and doc comments included, is removed together with the separator that followed it, so no blank-line run stands where it stood. When the configured language engine advertises `textDocument/references`, a standing reference refuses `unmet_precondition` naming `no_references`, unless `force` applies the removal anyway and carries the references as a warning. Without such an engine, the removal applies and carries a warning naming why it was not checked.
The removed span reaches back over the declaration's attached outer attributes and doc comments,
the same span replace_symbol uses, and forward over the separator that followed it, so removing
a free function leaves no blank-line run where it stood.
Before it writes, the server checks the declaration's references against the language
engine configured for its language. A standing reference
refuses unmet_precondition naming no_references, unless force applies the removal anyway
and carries the references as a warning. Without a configured engine, or one that does not
advertise textDocument/references, the removal applies and carries a warning naming why it was
not checked.
An engine that answers with no references before confirming its own readiness is a third case:
that answer looks the same as one from an engine that checked and found nothing, so the removal
refuses temporarily_unavailable instead of applying. force overrides this refusal too,
carrying a warning that the engine's readiness was unconfirmed.
remove_node
Since v0.0.19Removes one syntax node through a witnessed address from `nodes`. The server recomputes the witness before writing and refuses when the bytes drifted, so a stale address never removes moved code. When the node names a declaration, the removal is checked against the configured language engine's references the same way `remove_symbol` checks them; a node naming no declaration applies unchecked, with a warning saying so.
remove_node reaches below a declaration through a witnessed node address from
nodes, the same way replace_node does. When the node names a declaration, its
references are checked the way remove_symbol checks them; a node naming no declaration applies
unchecked, with a warning saying so.
Change results
The change tools return the same ChangeResult. A request whose shape is wrong - an
absolute path, a malformed patch header - fails as invalid_request before the server resolves
anything.
Otherwise the server resolves the whole operation before writing anything, and the result is one
of three things.
Refused, when resolution produced no edits. The targeted tree is untouched and
RefusalReason says what stopped it: the
address hit several targets, a witness or patch context no longer matches the bytes, or a
no_references precondition failed because remove_symbol or remove_node found a reference
still standing. A replace_node whose listing went stale shows the shape:
Request:
{
"node": "rift://node/rust/src/lib.rs@220-268#3f9a1c2e",
"body": "self.index.search(query)"
}Response:
{
"status": "refused",
"reason": "unmet_precondition",
"preconditions": [
{
"kind": "source_unchanged",
"status": "failed",
"addresses": [
{ "kind": "node", "node": "rift://node/rust/src/lib.rs@220-268#3f9a1c2e" }
],
"paths": ["src/lib.rs"],
"expected": { "kind": "text", "value": "3f9a1c2e" },
"observed": { "kind": "text", "value": "b81d02aa" }
}
]
}Applied, when final bytes differ from original bytes after transform hooks. At least one path
changed. The result's summary carries change id, the files the change wrote, diagnostics, and
guarantees passing validation hooks established. Files are sorted by project path; transform writes
are part of same summary.
Each entry in files names one file, whether the change created, modified, or deleted it, the
file's size_bytes and line_count after the change, and the lines_added and lines_removed
the change counted. A line whose content changed counts once in each; a deleted file reports zero
bytes and zero lines.
Unchanged, when direct edits landed but successful transform hooks restored original tree
bytes. Response is { "status": "unchanged" }: no change id, file, index rebuild, or engine
diagnostic is reported for bytes that did not remain changed.
Projections
Planned| Tool | What it does |
|---|---|
projection_create | materializes a pinned workspace snapshot |
projection_list | lists projections and the state of each |
projection_remove | deletes one projection, unpublished changes included |
projection_restore | refreshes changed paths from the workspace |
publish | writes one projection's changes into the workspace |
publish takes the list of change ids whose confirmations the caller accepts, and writes
nothing unless it can write everything:
- a change carrying a confirmation the call didn't accept comes back in
unaccepted - a path the projection changed that the workspace changed too comes back in
conflicts - a file returned by a projection read that the workspace changed comes back in
dependency_conflicts
Before those checks, the server compares the directory against its manifest. A process-written delta
becomes one filesystem change with exact paths, current provider and hook evidence, and an
external confirmation. If this scan discovers new writes, their ids return in unaccepted;
the caller reads rift://changes and retries after reviewing them.
Projection-scoped get_symbol, search, and nodes calls record exact returned files and their
digests. publish checks those read dependencies even when the projection did not change them.
This prevents a change to src/app.py from landing after the declaration it read from
src/config.py moved in the workspace.
max_projection_dependencies and max_projection_dependency_bytes bound this read set. A read
that would cross either limit fails before returning its data.
Each DependencyConflict carries path,
observed, and current. The caller can reread and revise its work, or pass that exact object in
accept_dependencies. An acceptance with an older current digest does not match after another
workspace edit and remains in dependency_conflicts.
An empty search has no returned file to record. Reads performed directly against the projection directory also bypass the read set. A caller whose work depends on either result reads again before publishing.
For every refusal, the server leaves both trees unchanged. Publication is serialized
workspace-wide, so a publish that cannot take the workspace inside the call budget fails with
temporarily_unavailable and is worth sending again once the holder finishes. On success the server
writes every changed path, removes directories its deletions left empty - the workspace root
excluded - rebuilds the pinned projection from the resulting workspace, advances its base
revision, and clears its changeset and read dependencies.
projection_restore goes the other way: it refreshes selected changed paths from the workspace
and drops the changes that touched them. A change is dropped whole - naming one of its paths
restores every path it touched, because restoring one path of a change without the others would
leave a change record that no longer describes the tree. Restored paths take their new conflict
baseline from the current workspace; other paths retain their earlier baselines.
Execution
Plannedexecute evaluates a block of code in the targeted tree's execution copy. The
copy persists between calls - a dependency one evaluation installs is still there for the next -
and the server refreshes its visible files to match the targeted tree before each evaluation, so
the code always runs against the tree as it stands.
Writes addressed inside the execution directory stay there and are not synchronized into the targeted tree. The execution directory is not a sandbox; the runtime uses the server's OS permissions, so an absolute path can reach outside it.
A workspace already at the max_concurrent bound of the
[execution] section returns temporarily_unavailable. The
caller may send the same request after an evaluation slot becomes free.
execute needs rift.toml to allow the exact language and a runtime to serve it. A call without
both refuses with capability_unavailable.
Errors
A refusal is a normal tool result: the server understood the operation and declined to change the
tree. ErrorData reports request and infrastructure failures such as invalid input, a crossed
limit, or a storage failure.
Every one carries a retry directive, which is the field to branch on - it rides each error
instance rather than the code, because the same storage_failure can be transient once and
permanent the next time:
never- the same bytes will fail the same waysame_request- it was transient: an index still filling, a tree with a filesystem write in flightoperator_action- the workspace requires a local state or configuration change before retry
A limit_exceeded also carries which limit and by how much, so a request can be resized without
parsing the message meant for a human.