Manual 0.8.0

Driving the engine from your own client

Who this is for: anyone connecting something other than the Claude Code CLI or the Claude desktop app to a Revetly instance — another agent host, an editor plugin, an internal tool. You need: an MCP client that speaks the 1.x protocol generation, and either a way to spawn a process on the instance's machine or network reach to a served endpoint (§1). Not here: the CLI commands (engine-config-surface.md covers what they read) and extending what the engine can read or write (writing-a-connector.md, writing-a-write-adapter.md).

The engine serves one MCP surface. Everything an agent does with Revetly goes through it, and it divides in two: tools that answer from CONTEXT, and tools that write to this instance's sources. Two of the writes are gated on a human, and that gate is the part a new client gets wrong.


1. Connecting: stdio, or Streamable HTTP

Two transports, and the first one is the default.

stdio. revetly serve runs the server on a pipe your client spawns and owns. It is what every .mcp.json this engine scaffolds points at, it is unchanged, and it authenticates nobody because it does not have to: the caller is whoever started the process. If your client can spawn a subprocess on the machine the instance is on, this is the one to use.

Streamable HTTP. revetly serve --http binds an endpoint — /mcp by default — for a client that cannot spawn anything: a hosted chat, a browser app, an agent on somebody else's machine. Same server, same tools, same payloads; what differs is who can reach it and what they have to present. On an instance that declares [auth] the image's revetly supervise starts it rather than somebody typing it, so docker compose up -d starts it, a reboot brings it back, and revetly doctor says whether it is answering.

SSE is not served. It is the previous generation's remote transport. /sse and /messages/ answer 404 with a body naming what this engine does speak, rather than a bare 404 that reads as the wrong address.

The address decides whether you need a token

An endpoint on loopback authenticates nobody, exactly as stdio does — the caller is already on the machine. An endpoint on anything else does not start unless the instance declares [auth] with a verifier that resolves, and that refusal is at startup rather than a warning afterwards. So there is no Revetly endpoint you reach across a network that answers you anonymously; if one answered you, you were on its machine — or something in front of it carried you to a loopback bind, which is a deployment the operator declares ([served] public_url) and revetly doctor warns about, and which does not change what any of the above requires of you.

The address you are given is not the address the engine binds whenever anything is in front of it. That is the operator's to declare and yours to be handed: the resource in the document below is what you ask for a token for, and it is compared, never fetched to find the endpoint.

Getting a token, without being told out of band

Call without one and the answer is a 401 carrying WWW-Authenticate with a resource_metadata URL. Fetch it — RFC 9728, served at the resource's own path under the well-known root, and at the bare root as well, with the same document either way:

{
  "resource": "https://revetly.example.com/mcp",
  "authorization_servers": ["https://auth.example.com/"],
  "scopes_supported": ["revetly.read"],
  "bearer_methods_supported": ["header"]
}

authorization_servers is where you go and authenticate. Ask for a token for this resource (RFC 8707) — a token minted for something else is refused here even when its signature is perfect, and under the jwt verifier so is one carrying no audience at all — and present it as Authorization: Bearer <token> on every request. A session is not a credential: the token is checked on each one, so a token withdrawn mid-session stops working mid-session, and a session belongs to the principal that opened it — another caller presenting their own token against it is answered as if the session were not there.

You are who the token says, on every call

The claims your token carries are bound to the request and are what a record this call records: the approver on a review_log, the author of a conversation note, the delivered_by on a delivery record. Not the server's environment, and not whoever started the container — over HTTP that would attribute every caller to one person, and a delivery record naming the wrong person is worse than one naming nobody. What is written down is the issuer's sub, with name and email where the issuer publishes them.

Every served call also leaves an audit line on the engine — subject, issuer, tool, project, outcome — because "who saw what" is a question somebody eventually asks about an endpoint more than one person can reach.

And you reach what your token is granted, not what the instance holds

Where the instance declares grants, the claims in your token also decide which namespaces answer. Three consequences follow for a client:

An instance that declares no grants narrows nothing, and everything above reads exactly as it did.

A connection bound to one project

revetly serve --project NAME, over stdio or with --http, binds every call on that connection to one declared project. It is a project somebody chose and wrote down where the agent runs, usually in the .mcp.json of the repository that project is about, so a call naming no project addresses NAME, and a write naming none lands there rather than being refused. Every answer on the connection states its project, writes included: "project": "NAME", or the project an artifact lives in where it was read from one NAME imports.

A call naming another project, alone or in a list, is refused before the tool runs, with the code bound-to-another-project, the bound project under bound_to, and a fix naming the call that would be answered; a write refused that way carries wrote and wrote nothing. Reads still reach what NAME imports, and context_health still reports each imported project's freshness. list_reviews and raise_review see the bound project's reviews only. A grant still applies: a bound project outside the caller's grant is refused as any project outside a grant is, and a grant of several projects is narrowed to the bound one. An undeclared NAME stops the server before it answers anything, naming the declared projects. An unbound connection is unchanged.

What is the same over both, and what is not

Every tool in §2 answers identically over either transport, with the same payloads. What changes in practice is the review gate: a host that reaches the engine over HTTP is usually a host that renders neither an elicitation dialog nor an app, so what is the exception over stdio is the common case here — the draft parks as awaiting-review and waits for a human somewhere that can render one (§3, §4).

The tool list changes, and this engine now says so

At initialize this engine declares tools.listChanged: true, and it means it: a session gets notifications/tools/list_changed once, on the first call it makes, unless that first call is tools/list — a client fetching the catalogue already has the catalogue and is told nothing.

Read the declaration and act on it. The tool set here changes with the engine — new reads arrived in one release, withdraw_review in another, and a later one folded seventeen model-visible tools into twelve — so a catalogue cached across an upgrade is a catalogue missing tools or holding removed ones. A call to a removed name is refused by name with its replacement named — a sentence, not an unknown-tool error — but only where the host delivers the call at all, and the failure it produces is not one this engine can see: the host refuses the call before it is sent, so there is no line in the audit trail, no error in the log, and nothing to debug from this side. It was measured once as exactly that: a reviewer pressing a Withdraw button that rendered correctly, a host-side "no such tool", and an engine that never heard.

Until 2026-08-27 this engine declared false, which is a promise that the list is static, and any client that cached forever on the strength of it was doing what it was told. If yours did, the fix on your side is to list again — the declaration and the notification are both there now.

One shipping host was measured ignoring both on the day they landed, so neither is something to assume works: see §6.

Over Streamable HTTP the notification goes out on the standalone GET stream and nowhere else — it is unrelated to any request you have running, which is what that stream is for, and a server must not put the same message on two streams. So open the GET stream if you want to hear it. A client that does not is not broken and nothing is withheld from it: no announcement reaches it, and the listChanged: true it read at initialize still tells it the list is not static. Open it whenever suits you: a session that makes its first call before its stream has attached is still told, once, as soon as it does — the announcement waits a few seconds for a stream to carry it rather than being written into one that is not there yet. Over stdio there is one stream and the notification simply arrives.

Nothing is added to the SSE stream answering a request. The first data: event on a POST's stream is still that POST's answer — though routing by JSON-RPC id rather than by position remains the right way to read it, because nothing in the specification guarantees that in general.

2. The tools

Reads. None of these change anything, and every one of them is safe to call speculatively.

tool answers
search_context hybrid search over every artifact and extracted fact
get_artifact one artifact by id: by default the brief (its own words and declared links), with history_after the full record, with depth the branch the sources declare beneath it (section 2b)
list_backlog the backlog by status, provenance and PBI type, as compact rows (section 2b)
get_scope three questions against one scope — question='drift' (what was asked for against what shipped), 'deltas' (what changed over a window, on a clock the caller picks), 'binding' (what already binds the scope; with path, which stories and decisions are behind a file)
get_authoring_context the bundle to draft from: nearest stories, their documents, their paths, and what binds them
context_health whether the last read pass landed, per source, plus the corpus revision — and, given a capture handle, whether one write has reached CONTEXT yet
list_reviews which drafts are waiting on a human here — those in projects this caller may reach; status is pending (the default), applied, refused, resolved, withdrawn or all
review_state whether one review has been answered — app-only

Every read has a shell twin, answering the same question with no client at all: revetly search, revetly inspect (one artifact), revetly branch, revetly drift, revetly deltas, revetly binding, and revetly status for what context_health reports. They are the same code behind a different door, which is what makes them the pair to reach for when the question is whether the engine or the client is wrong.

Writes. These change source files in the instance repo and commit them.

tool writes gated on a human
submit_draft a new story — where the project's store lists several repositories, repository (owner/name) says which one it is filed in yes
submit_revision a change to an existing story yes
set_status a status flip (draft, ready, in-progress, done, parked, discarded), with a claim recorded on in-progress and a verdict per criterion when shipping — and, on a work item another system holds, the delivery record alone, with no flip anywhere (did says which). A ship through a GitHub, Azure DevOps or Todoist write adapter also posts the record as a note on the item, named under delivery_note, unless [backend] tracker_note = false no
record_conversation a conversation or decision note no
raise_review puts a waiting review back in front of a human no — it carries, it does not decide
resolve_review the human's answer, from the review app — app-only it is the answer
withdraw_review a human taking a review off the list without deciding it — app-only it is the answer, and it is not a verdict

The ungated writes are ungated deliberately: a status flip and a capture are auditable, reversible metadata about work that already exists. A draft is new content asserted by a model, and that is what a human has to accept.

in-progress records who has the item. The flip records a claim: who started (claimed_by, so an agent can name itself, else whoever the call resolved to) and when. The answer carries it as claim (by, at, and reason and taken_over_from where they apply). Setting it again as the same claimant keeps the original start. A second claimant is answered "status": "refused", naming the holder and their start time, with the holder's claim beside it, unless their reason says why they are taking the item over; the takeover and its reason are then recorded. Any other transition clears the claim. Where it is kept depends on the store. The repository store keeps a claim block in the story's frontmatter, committed with the flip. GitHub keeps the issue open with an in-progress label, which has to exist in the repository (revetly doctor names it until it does), and one claim comment edited in place, named under claim_note. Azure DevOps lands the item in its type's first state in the InProgress category, with the same comment, and refuses a type with no such state. Todoist refuses in-progress: a task there is open or completed. A store that keeps no claims of its own gets one JSON file per item in the instance's records store, and the answer says so under claim_kept: a second claimant is still refused, but people reading that store do not see who has the item. A claim that could not be recorded reads "claim": null with claim_not_recorded saying why.

A recorded decision can supersede an earlier record. Each entry in record_conversation's decisions takes an optional supersedes list of record ids. Use it only when the new decision replaces the named record whole — the older note is then marked superseded_by, weighed 0.5 in search and sorted after live statements in binding, from the frontmatter alone rather than from extraction. A partial change is not a supersession: name the record in related_ids, so it reads as bearing on the decision rather than retired by it. An empty supersedes writes no key, so a capture that replaces nothing is byte-for-byte what it was before the field existed.

A recorded decision can revise part of a work item. A decision that changes one criterion of a story leaves the rest of it standing, so it is not a supersession. Each decisions entry takes an optional revises list for it: a bare work-item id, or {id, criteria: [n, ...]} naming the acceptance criteria it changes by 1-based position. Every target is checked before anything is written: an id that is not a live work item, or a position the item does not have, is refused by name and nothing lands. What passes is written to the decision note's frontmatter beside supersedes, in the store's own form of the id, with a fingerprint of each named criterion's text as it stood (the first eight hex digits of a sha1 of the criterion, whitespace collapsed and case folded):

revises:
- FEAT-000
- id: STORY-000
  criteria: [1, 3]
  criteria_fp: [a1b2c3d4, 0f1e2d3c]

criteria_fp is optional, so a note can be backfilled by hand with id and criteria alone; it loses only the reworded-since check below. revetly check names a malformed entry (no id, a position that is not a whole number from 1, a fingerprint list that does not match the positions) and an id CONTEXT does not hold. The linker states each entry as a revises edge from the note to the item carrying the positions and fingerprints, re-asserts them every pass, and retires the edge when the note stops declaring it. An empty revises writes no key.

A story keeps one list of criteria. Both gated writes take a description and an acceptance_criteria list, and an agent that writes the body it would read back routinely ends the description in its own ## Acceptance criteria section. The two writers render the file through one body function and meet that shape with one rule: a trailing section that says what the field says, line for line, is folded and the story carries the list once; a section that differs is refused before the reviewer is asked, naming the first line that differs — it is never appended, because a story carrying its list twice reads back with twice its criteria and set_status … done is then asked for twice the verdicts. A criteria section that is not the description's last section is refused the same way. A story already doubled on disk is named by revetly check and folded by revetly check --repair, which keeps the last list (the field as approved), moves no other line, and records the repair as an amended entry on the review_log.

Three tools are the reviewer's surface (resolve_review, review_state, withdraw_review) and what that obliges you to do — and what this engine does and does not check — is section 3a below. Read it before you expose any of them to a model.

2a. Prompts and resources: grounding that travels with the connection

The five recurring gestures are served as MCP prompts — revetly-ground, revetly-draft, revetly-capture, revetly-decide, revetly-ship — each taking one argument and returning the same instructions the scaffolded command files carry. A client with no scaffolded guidance lists them with prompts/list and grounds the same way a scaffolded session does. Prompts are instructions for the model, never a write path: everything they name still lands behind the review gate below.

Three resources ride beside the tools:

2b. What a read's description leaves to this page

The tool descriptions are kept to what a model needs to call the tool well, a few hundred bytes each. Everything below is the rest of what each read means, stated once here rather than as constant prose on every answer. The payloads keep the keys and counts; the sentences explaining them are here.

Holding an id, read it with get_artifact; search only when looking for something. A search still answers an id query as it does today: it ranks and fuses, and may serve a newer note that revises a decision above that decision. An id from a configured id family (the backlog's) gets an exact-id signal; a note id (DEC-, CONV-, DEL-) does not. get_artifact serves the one record verbatim, with its declared links.

Compact rows. list_backlog rows and the nodes of a branch carry artifact_id, title, status, artifact_type, source_updated_at and, on a backlog row, the parent its source records. Where they apply they also carry project (on a multi-project instance), superseded_by, discard_reason, the deletion marks (deleted, deleted_at, deleted_clock, deleted_note) and provenance when it is not source-of-record. A branch node carries kind where it has no type. name and native_id are not served: on an ingested artifact both are its id again. Everything else a source records (owner, addresses, attribution, connector metadata such as an Azure DevOps item's area and iteration paths) is get_artifact on the id.

What sits outside the backlog. A tracker item that no type map covers ingests with its source's own word as its kind (issue on GitHub, the work item type on Azure DevOps) and is not on the backlog. No default type is applied. list_backlog names these items under outside_backlog, whatever status or pbi_type was asked: count, and items with one row each giving artifact_id, title, the source's own kind and why. why is untyped for an item with no native type and no label the map names, and unmapped for an item whose native type the map leaves out, named in native_type. A row lists the item's labels where it has any. A read spanning projects keys the block by project. The rows get a quarter of the read budget; a cut is named in outside_backlog_truncated and the count stays whole. The block is read from what ingest stored, never from the tracker, so an item typed on its source leaves it on the next pass. A store with no type map reports an empty block. context_health carries the same count as outside_backlog with the fix: set the type or a mapped label on the source, or extend [connectors.*.types]. Doctor's untyped items and unmapped items lines count the same set.

get_artifact serves the brief by default. Asked with no argument but the id (and project), it answers with the artifact's own words, body and acceptance_criteria, verbatim (a body too long for the read budget is cut to its head and says so under body_truncated). A criterion is a line under the body's ## Acceptance criteria heading that starts - , * , or an ordered marker (1. or 1), any number, then a space), marker stripped, wrapped lines rejoined; a task-list [ ] stays part of the criterion's text. A work item whose body has that heading and no such line under it carries criteria_unparsed: true beside its empty list, so the empty list is not read as "the source declared none", and the ingest note counts those items per connector. It serves its title, status, artifact_type, kind, owner, provenance, address (source_uri, source_path) and source_updated_at; the parent its source records; any open_questions; its declared links as declared_links, a map of relation to the ids at the far end (a note that revises a story lists it under revises); and the marks a row never loses. A work item a decision note declares it revises carries revised_by: one entry per revising note, newest first, with the id, the criteria positions it names, and criterion_changed: true where the note fingerprinted a criterion whose text at that position has since changed or is gone (false where it still matches, absent where the note carried no fingerprint). full_record counts what the brief leaves out: inferred_links, history_entries, change_notes, the metadata keys, and whether an inferred_summary is held. history_after serves the full record, with '' meaning "from the first history entry"; deleted serves it too, widened to deleted and retracted links. The full record is:

A deleted id answers with its record marked deleted (an absent deleted_at means unknown, never recent). A withheld one answers from the ledger with in_context: false. With deleted='include' a link this instance declared was never true is served marked retracted with its reason, and a neighbour retracted as a concept carries other_retracted; deleted='only' serves the withdrawn links alone.

The branch (get_artifact with depth). An integer walks the declared hierarchy that many levels (0 is the root alone); depth='declared' walks as far as [backlog.parents] declares, and depth_source says which answered. The tree is built from source-declared relations only. inferred beside it counts the edges a model inferred about the branch's artifacts (total, unnamed_endpoints) and lists a sample (listed, one per artifact in turn so it spans the branch); none of them is in the tree, and none adds, removes or re-parents a node. total_is_floor means the count stopped at its scan cap. rollup counts the walk per status and per type, each count carrying its ids. truncated, cycles and withdrawn are the three ways a branch can be smaller than the graph; a withdrawn node takes everything beneath it out of the branch. history_after is refused on a branch: history is paged on one artifact.

get_authoring_context. Draft grounded in the bundle's backlog and their linked documents, and cite the artifact ids that informed the draft. The nearest backlog is the engine's keyword leg, fused by rank with Graphiti's recipe on a full instance (method names the legs). Each backlog row is a slim brief: id, title, status, parent, acceptance criteria and declared_links (each in a room of its own, acceptance_criteria_truncated and declared_links_truncated counting what was left out), and the head of the body (200 characters), with body_truncated where the head is not the whole; get_artifact on the id serves the rest. related is a search at the bundle's limit; an artifact or passage the backlog already serves is named under related.artifacts_in_backlog, not repeated. Up to forty-five binding statements ride in the bundle, without their source_path, source_uri and empty deletion marks, fitted before the backlog's tail, so the bundle never serves an empty binding.statements while the scope has statements. The first fifteen are served whole; the rest are pointer rows carrying only artifact_id, title, bears_on and stated_at, with binding.pointers (from, note) saying where the passage is served (get_scope(question='binding') or get_artifact). A backlog row whose item a decision declares it revises carries revised_by, as get_artifact serves it: the revising notes' ids, the 1-based acceptance_criteria positions each names, and criterion_changed. The bundle says each revision once, so binding.scope.revised leaves out every story a served backlog row already marks this way. Statements a budget cut took are named by id under binding.truncated_ids, in rank order, each id once; the names are paid for out of the budget and are cut last, with binding.truncated_ids_more counting any left out. An item marked superseded_by is retired; its assumptions are not current, so prefer the superseding one. An item with status discarded was decided against with nothing replacing it, so do not build on its intent. An item marked deleted is gone from its source and is here only because deleted work was asked for: lineage, never a basis for new work. binding is what already binds the backlog the bundle found. The binding topic scope is also run over the drafting intent's own words, and the decision, constraint and assumption notes it matches join the shelf after the statements the resolved backlog's walk reached, each note once; if that topic gather fails, the shelf keeps the walk's statements and says why under binding.topic_error. Answer to it: a draft that contradicts a decision is the expensive mistake the bundle exists to prevent. A draft is ai-derived until a human reviews it and it is written to the owning source. A bundle that found no backlog says binding gathered nothing.

get_scope(question='binding'). It gathers and ranks the material that bears on a scope, and it does not judge it: whether anything there contradicts what you are about to write is yours to decide from the statements and their provenance. It never reports that nothing conflicts, and an empty statements list means nothing was gathered, never that nothing binds this. An artifact scope is the artifact, the branch beneath it, and what it hangs under: a decision stated against an epic binds a story drafted under it. A topic scope is matched on the words in the topic by the engine's own keyword index over each artifact's own title and body (never a model's summary, never a concept, never an embedding): BM25 with English stemming, the title weighted up, weighed by the share of the topic's words the artifact carries, so a short note about the topic ranks ahead of a long record that mentions each word once. It keeps the artifacts carrying most of the words the best match carries and scoring within reach of it (scope.weaker_matches counts the rest), then walks outward from what matched; a decision note the topic matched is served as a statement itself (method: topic-match). When every word in the topic is a stopword or shorter than three characters, nothing in it names an artifact and scope.matched is empty with a scope.note saying why, rather than the empty list a thin corpus would give. Naming an artifact is the stronger scope. Within the stated tier and within the inferred tier, statements are ordered by where they bear: under a topic, first the ones whose own words (title, fact, passage) carry more of the topic's; then the stronger claim (method) first; then the scope's own, then what hangs beneath it, then what it hangs under, each newest first. A decision note that names a scope member by id in its own body (method: explicit-ref, which is how record_conversation's related_ids land) ranks with that member's own citations (method: cites), so the decisions recorded about a story after it was drafted sit beside, and being newer usually ahead of, what it cited when drafted. What the scope hangs under keeps a few statements per ancestor and ancestors_summarised counts the rest with a pointer: read the ancestor itself to see them all. A statement whose note a later one superseded follows the live ones and carries superseded_by; an inferred statement whose every source episode was revised, withdrawn, superseded or discarded follows the live inferred ones, carries sources_retired, and is counted under inferred_retired — demoted and marked, never removed. gathered_from names the document types and entity shelves statements are gathered from. Where entity_types_present is empty, no prose-only statement has been extracted as its own entity on this corpus: the instance declined the three types, its prose was ingested before them (the operator's revetly migrate prices the re-read), or the prose states none. On a lite instance it reads absent: nothing prose-only can be extracted there. A statement cap that cut every prose-only statement names the shelves under truncated.cut; narrowing the scope brings them back.

A scope member a decision note declares it revises (its revises: frontmatter) is marked on the scope: scope.revised lists each such member, nearest the scope first, as {artifact_id, revised_by}, and revised_by is exactly what get_artifact serves on that item — the revising notes' ids, newest first, the criteria positions each names, and criterion_changed where a fingerprinted criterion's text has since changed or is gone. A declaration a later pass withdrew marks nothing, and a scope nobody revised carries no revised key. The drafting bundle's binding.scope carries the same mark for each member no served backlog row already marks with its own revised_by, and no revised key when every revised member is such a row. Whether the revising note is also served as a binding statement is the module constant binding_order.BINDING_SERVES_REVISING (off until measured): on, the note is reached by its declared revises edge and served as a stated statement (method: revises, ranked after supersedes and ahead of cites, then by distance and newest first), inside the read's caps; off, a statement reached over a revises edge alone is not served, nor is the revising note a document the read steps through to what it states or cites, and the note is named only by the mark, while a note the scope also reaches another way (a citation, a reference by id) is served on that row. The mark rides search's lever, retrieval.REVISED_BY_MARK.

get_scope(question='binding', path=...). Which stories and decisions are behind a file. The engine runs git log --follow -- <path> at query time in each repository [connectors.commits] declares (the path as written relative to a repository's root, or prefixed with its name or declared path) and resolves each commit two ways: the ids its message names, and the delivery records whose implemented_by names it. behind lists up to five stories, then up to five decision notes, each with artifact_id, title, kind and a one-line why; a story also carries commits (how many commits touching the file name or delivered it) and last (the newest one's date). Stories are ranked by weight summed over the commits: one where the subject line names the story, half where only the body does, one more where a delivery record says that commit delivered it, plus three for the story the file's first story-naming commit names, because the work that brought a file in is what it exists for. Ties go to the more recent commit. Decisions are the notes the leading stories and their delivery records cite or are cited by. Notes that name the file (its path, its file name, or module.attribute) come first, then notes linked to more of the leading stories, and higher-ranked ones, then the newest. path.repos says which repositories held the file and over which dates. The binding statements that follow are gathered over the three leading stories, as for an artifact scope; more_ranked counts the stories past the first five. Nothing is stored: paths stay in git and are never written to the graph. A path no declared repository has history for, or an instance declaring none, answers an empty behind with a reason, never an error. path does not combine with artifact_id, topic or depth. No model is asked, so a lite instance answers it in full.

get_scope(question='drift'). Every line is stated: the verdicts were given by whoever shipped, the criteria are the story's own wording quoted at delivery time, the record-to-story link is a delivers edge its source declared, and the hierarchy is the one the sources declare. provenance: {"inferred": false} says so. done_without_record is a flip that discarded its verdicts or a ship whose record did not land; this read does not settle which, and neither is clean. no_criteria_declared is a delivery against a store declaring no criteria: nothing was ruled, what shipped is stated as given, and its departures are in departures with criterion_declared: false. clean is delivered with a record, every declared criterion ruled, every verdict held. In a window, a delivery counts where the time its record states falls inside it, and a done item where its own review log states the flip inside it.

get_scope(question='deltas'). clock='source' measures the window on what each source says about when the change happened; an artifact whose source declares no time cannot be placed on it and is in undated. clock='ingest' measures it on when this instance learned of each change, from the ingest ledger: "what is new to me since I last asked", never when anything happened (a backfill of 2019 read last Tuesday is in last Tuesday's window). Every served time names its clock. provenance: {"inferred": false}: the answer is built from source-declared times and deterministic edges only. edits: {"bodies_read": false} says a revised change carrying edit_date is one commit dated off the source's change log; what the artifact said at that moment is a version no ordinary pass reads. links: {"counted": false} says a change to an artifact's links is not counted as a change to it: a declared edge has no source-declared time of its own. external_references counts ids other sources name that were never ingested; nothing dates them, so they are out of scope rather than reported undated.

Cuts. Every cut keeps its key and one short reason: truncated_*, *_truncated, truncated and withdrawn name the reason code and the counts, and a sentence of a few words. Ranked lists cut their weakest tail. Where the answer was cut, the whole of it is reachable by narrowing: a shallower branch, a sub-branch by its own id, a window, an artifact in place of a topic, or get_artifact on the id. invalidated.count is how many facts whose source invalidated them are out of a search; deleted='include' reads them marked. inferred_retired.count is how many inferred facts or statements were read only out of bodies their sources have since revised, withdrawn, superseded or discarded: they follow the live inferred rows, each marked sources_retired, and none is removed.

3. The review gate, which you must render

submit_draft and submit_revision put a draft in front of a human. The engine decides how from what your client declared at initialize — never from how a call came back, because a non-accept answer means "not approved" and nothing more, and inferring a broken client from it would relabel every genuine rejection.

Whichever case your client is, the review goes on record first: every submit opens a pending review with a REV- id before any transport is asked, and the transport only answers it. A client killed mid-review leaves a pending review any session can answer — never a lost draft.

Four cases, and your client is one of them.

You declare url-mode elicitation, on an endpoint with a declared issuer. The engine hands your client a one-time link to its own review page (/review/REV-…?nonce=…) via a URL elicitation. The human opens it, signs in at the instance's issuer, and answers on that page; the decision arrives over plain HTTP under their verified token — there is no MCP tool that answers it, so nothing a model session holds can. The call returns awaiting-review, and your session is sent notifications/elicitation/complete when the page answers. This is the transport that is enforced and attributable at once, and it is preferred wherever it renders.

You declare the (form) elicitation capability. The engine renders the review as elicitation dialogs and the call blocks until the human answers. A long draft is paginated across several viewport-sized dialogs ending in a decision page, so expect more than one round trip inside one tool call. Each dialog's message is the page's header and its navigation line; the page's body is the description of the field the dialog asks for (proceed on a content page, approve on the decision page). That shape is the terminal client's: Claude Code shows three lines of a message, whatever the terminal's height, and a field's description whole up to 2,000 characters — in its inline TUI. Its fullscreen TUI ("tui": "fullscreen" in settings) clamps every field to one line, so review under "tui": "default". The response comes back approved or rejected with the reason, and the review record closes with it (resolved_via: mcp-elicitation). A dismissed dialog leaves the review pending rather than declining anything.

{
  "capabilities": { "elicitation": {} },
  "clientInfo": { "name": "acme-agent", "version": "1.4.0" }
}

You declare the MCP Apps extension. The engine hands back an app resource for you to render beside the conversation, the call returns immediately with "status": "awaiting-review", and the human answers later through resolve_review. Declare it under extensions, naming the app mime type:

{
  "capabilities": {
    "extensions": {
      "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] }
    }
  }
}

The app is preferred where both are declared: it shows the whole review at once rather than paginating it, and the reviewer can take their time without an open call waiting on them. The app resource ships inside the engine and fetches nothing from any origin but your host, so an instance behind a firewall reviews drafts exactly like one with egress.

You declare neither. The draft is not written, and the response says so naming what your client would need. It is not reported as a human who declined — nobody saw it. The review is also parked on record rather than dying with the call, carrying a review_id: any session on that instance which renders a gate can find it with list_reviews and put it back on screen with raise_review.

Which of the three you got, in the engine's own words

You do not have to guess which case your client fell into. The first time a session asks for anything, the engine writes one line on the revetly.declared logger saying what it read out of your initialize and which gate that produces:

client declared: openai-mcp 1.0.0 | protocol=2025-11-25 | elicitation=False |
io.modelcontextprotocol/ui=True (mimeTypes=['text/html;profile=mcp-app']) |
gate=app | sessions from this client since start=3

Read it for the case that is otherwise invisible: a host that renders UI under an extension name this engine does not look for is reported as other extensions declared=[...], which is a different problem from declaring nothing at all and needs different work. The trailing count is per client name since the process started — a client that re-initializes on every call says so there rather than in one line per call.

It is a diagnostic and decides nothing: the gate reads your declaration itself. It is written once per session, only over HTTP (the stdio pipe answers the operator who started the process, so there is nobody to tell apart), and it can never fail a call.

3a. resolve_review, review_state and withdraw_review are the reviewer's surface

All three carry _meta.ui.visibility: ["app"]. In the MCP Apps extension that means the host MUST keep them out of the agent's tool list and MUST reject calls to them from anywhere but the app. If you are writing a host, that is your obligation and this engine cannot discharge it for you.

withdraw_review is on this list even though withdrawing is not deciding. It writes no approval and no rejection and nothing reaches source-of-record — but it is the other way a review stops waiting, and an agent that could clear a review inconvenient to it would have got what an approve tool would have given it by a shorter road.

Here is exactly how far this engine backs that up, because the difference matters and stating it as one property would be a lie.

If your client declares the elicitation capability and no MCP Apps extension, the three tools are not offered to your client and refused if you call them regardless. Nothing on that connection could have rendered a review, so there is no legitimate caller for them there — and your human has a gate of their own, so the refusal takes nothing away from them. The refusal names what happened, writes nothing, and leaves the review pending — it is not a rejected review and must not be reported as one:

{
  "status": "app-only",
  "detail": "resolve_review is the review app's own surface, and acme-agent 1.4.0 declared no app to render it in ..."
}

Your human still answers reviews. list_reviews finds what is waiting and raise_review puts one back on the gate you do render, choosing it the way a submit does: a dialog, answered inside that call — or, where you declare url-mode elicitation on an endpoint with an issuer, a freshly minted one-time link to the review page, after which the review's earlier link answers nothing. Their third answer is there too: the decision page of a re-raised review carries a withdraw field, which takes a review nobody will ever answer off the list without ruling on it.

If your client declares neither, nothing is withheld and nothing is refused. That is deliberate rather than an oversight: a host may render the review app as an MCP session of its own, and what that session declares is the host's business, not something this engine has any right to assume. Refusing every session that did not declare an app would eventually refuse an app — a reviewer stranded in the name of enforcement — so the doubt is spent letting the call through.

If your client declares the extension, all three tools are offered, and this engine cannot tell your call from your app's. They arrive on one session under one token, and the extension obliges no marker on the wire that a server could read. So on that transport app-only is a convention this engine documents, not a property it enforces: keeping the two tools away from your model is your side of the contract, and nothing here will catch you if you drop it. Hosts that render the app under a second client identity make it look enforced from the outside; that is one host's implementation, not the specification's promise, and this engine does not branch on it.

What the record says in its place. Because a decision made by a person at the panel and one made by a caller holding the same token used to read identically, every resolved review now carries what was actually observed:

{
  "decided_via": "mcp-app",
  "decided_channel": "the-session-that-raised-it",
  "decided_channel_detail": "answered on the same connection that raised the review, which is a connection a model drives — this engine cannot tell this decision from the model having answered its own review",
  "decision_gate_enforced": false
}

decided_channel is another-session where a different connection answered, the-session-that-raised-it where the answering connection is the one that raised it, and unknown where there is nothing to compare. None of the three is a claim about who pressed the button. A verified decided_by says whose token it was, which is not the same claim, and the record no longer implies it is.

If you need the guarantee, two gates carry it. On the elicitation dialog the answer travels back inside a prompt your host put to a person, so a model has nothing to answer with. On the URL gate the answer arrives over HTTP on the engine's own review page, under a token the instance's issuer signed — enforced and attributed to a verified person, on any client that declares url-mode elicitation. An operator who requires that a model cannot approve its own draft runs reviews through either; the app remains the convention documented above, and a url-capable client is given the URL gate in its place.

And the approver's identity has a floor. A machine actor never approves, whatever the transport. On a served endpoint a self-asserted name never approves. An approval that would be signed by the engine's own service identity — the container's baked-in git config, or the sentinel — is refused naming REVETLY_REVIEWER. And a verified principal does not approve a draft the same principal raised unless the instance declares [auth] approver_may_draft = true. Each refusal leaves the review pending and names the fix.

4. What awaiting-review obliges you to do

Wait.

{
  "status": "awaiting-review",
  "review_id": "REV-9f2a1c4d7b3e8051",
  "resolve_tool": "resolve_review",
  "withdraw_tool": "withdraw_review",
  "state_tool": "review_state"
}

The draft is not written. A second submit_draft does not retry anything — it puts a second copy of the same draft in front of the reviewer while the first is still live, and they now have two decisions to make where you meant one. The correct behaviour for a model-driving client is to report the id and stop: whoever is holding the session decides, and the record names them rather than whoever drafted.

An app-rendering client polls review_state(review_id) to know whether the review in front of it has already been answered on another connection — a human can answer the same review from another session, and a stale panel that still offers Approve is a second decision waiting to happen.

A review id is one REV-… segment. raise_review, review_state and withdraw_review answer an id of any other shape exactly as they answer a review that is not on record, and a stored review whose own review_id is malformed is read as a corrupt record, never followed to a file.

5. Reading a response honestly

These keys appear on read responses only when something is true, and a client that drops them will confidently report an empty answer as an empty world. They are conventions of the surface, stated once here rather than restated in every tool description: every read takes them the same way and answers them the same way.

The read budget — every read answers under a per-call byte budget (32 KB by default; REVETLY_READ_BUDGET on the engine raises it). The budget is structural: a collection stops adding whole rows before the answer outgrows it, and nothing is ever cut mid-value. Anything a budget or a cap removed is reported, never silent — a truncated block (or links_truncated, body_truncated, facts_truncated, source_change_notes_truncated, truncated_changed, truncated_departures on the read it belongs to) names the reason, what the whole answer holds, and what was listed. Ranked lists cut their weakest tail first, so stated material survives a cut that takes inferred material — and where one read fits several collections at once, the whole answer is measured too: the inferred links go first, then the history page (resumed by the cursor it serves), then the oldest change notes, then the declared links, and the artifact's own words last.

Paging — list_backlog takes limit and a forward-only cursor: pass the next_cursor a page served to resume it, in the read's own serve order. A cursor the corpus no longer carries is refused rather than silently restarted. history_entries on one artifact pages by history_after, as it always has, with history_total for the whole and history_next for the page after this one. The scope reads bound by limit and report what the bound removed; narrowing the scope or the window is what reads the rest.

The artifact's own words — get_artifact serves title, body and acceptance_criteria (a typed list) as the source states them, with criteria_unparsed: true where a criteria heading yielded none. On an artifacts row of search_context, summary is the stated title followed by the first 280 characters of the artifact's own prose body, cut on a word and marked … where it was cut: the same body always gives the same summary. inferred_summary is inferred. It is the paragraph Graphiti's extraction accretes on the artifact's node a clause at a time, and it can carry sentences about other artifacts. It is served beside the stated fields and never in place of them, fitted to two sentences unless inferred='all' was asked, taken off the rows (counted under truncated_inferred_summaries) before any stated row is cut to fit the budget, and never served on a lite instance. name is a graph entity's name. Neither is the artifact, and grounding on either when the words are beside them is the mistake this contract exists to remove. source_uri is an address that resolves outside the engine's own container — a web URL where the source has a remote, a repo-relative path where it does not; source_path remains where the engine read it.

Search serves live, citable material — search_context answers with artifacts (hits standing for an ingested artifact — stated material, first), passages (the artifact's own words around whatever a hit matched: the window of its prose holding the most distinct query words, stopwords ignored and words matched on a light stem, never the Document: … (id: …) envelope line or a work item's JSON fields) and concepts (extracted entities standing for none — real, and never grounding to cite) as separate lists. Facts whose source has invalidated them are out by default; invalidated counts what the filter removed, and deleted='include' reads them marked. Every fact carries endpoints — the ids of what it joins — so a cited fact is traceable. The shelf runs in three bands and is never longer than limit: the declared relations a source or a deterministic pass stated (a parent, a delivery, a supersession, an identity), then the statements a note states ("X states: Y"), then the facts Graphiti's own extraction inferred. The first two are read off the graph by the engine's own Cypher, on a full instance and a lite one alike, and each is ordered by its own fact text through the engine's fulltext index (BM25), with the link's declared rank only breaking a tie. A declared relation or a statement the fulltext index actually matched is served wherever it stands; one it did not match is served only where it touches one of the first three artifacts rows (the rest are counted under facts_truncated, reason not-near-top), and only into the room the matched rows leave, so neighbourhood padding can never push a relevant statement or inferred fact off the shelf. The inferred tail keeps the room inferred_cap names, and deterministic_facts counts the declared relations actually served and no more: a statement's sentence was read by a model, so it sits on the inferred side of that count, and the inferred cap applies to it as it does to the rest of the tail. The deleted argument reaches both bands as it reaches everything else: a statement is retired by its own retired_at, a declared relation by its invalid_at.

A citation, an id seen in a body and a title seen in prose are not fact rows: they say only that one artifact names another. Each artifacts row carries them as links, a map from cites, cited_by, references, referenced_by, mentions or mentioned_by to at most three far-end ids — ids this answer also serves listed first — with more counting the ones not listed. Live links only: a far end whose source withdrew it is not listed unless the read asked for the withdrawn (deleted='include'), the way every other withdrawn row comes back. get_artifact on the row's id serves every declared link it has.

Every artifacts and passages row carries method: "keyword (english stemming)" where the engine's own keyword leg matched it, "keyword (english stemming, feedback terms)" where only the feedback leg reached it, and where only Graphiti's recipe did, "graph recipe (BM25 + id-name vector)" on an artifact row and "graph recipe (BM25)" on a passage. The feedback leg asks the same stemmed index again with your words plus the most distinctive words of the first answer's best rows, so a note written in other words than the question can still be reached. Search weighs that second answer at half the keyword leg's weight, so a row only it reached ranks below a row the question itself matched; the drafting bundle's nearest backlog runs the same leg (get_authoring_context). The recipe's node leg compares the query with the embedding of the node's name, and an artifact's node is named for its id, so a row it found was not matched on its meaning and is never labelled as though it had been. The keyword leg never paraphrases — the only spelling besides the one you typed that it will answer to is a canonical term declared under [extraction.aliases] — and no match it makes is ever called semantic, vector or inferred. The inferred_summary an artifact row may carry is the node's paragraph, whichever leg found the row, and is marked as what it is. On a full instance the two run together, never as a substitute for one another. Declaring [llm] provider = "none" and [embedding] provider = "none" together is a lite instance: no vendor is ever called, and every read here answers through the keyword leg alone — every artifacts and passages row carries this method, concepts reads absent with the reason lite, facts carries the edges a source stated with deterministic_facts equal to their count — followed only by any statement a corpus already held from before it went lite — a sibling inferred_facts reads absent for the same reason, and nothing served is ever called semantic, vector, inferred or a concept. drift, deltas, binding, the backlog, an artifact's own branch and the authoring bundle answer with the same stated half a full instance serves, because none of that half reaches a vector; where one of them has an inferred half — an artifact's links, its branch, the statements binding gathers from prose — that half reads absent with the reason lite rather than empty. Naming a real provider on both sinks is what promotes a lite instance to a full one, and revetly manual show providers has the mechanics.

Ranking — artifacts and passages are ordered the same way on every answer, so it is described here once rather than on each answer. An answer the rerank did not order carries "ranking": {"applied": false}; an ordered one carries no ranking key.

First the legs are fused by rank. The keyword leg's scores are Lucene's and the recipe's are not on that scale, so each leg contributes 1 / (10 + rank) for a row at that place on it, and a row both legs reached stands at the better of the two, never their sum, so appearing on the weaker leg is no bonus over ranking higher on the stronger one. The recipe's share counts half: its node leg compares the query with the embedding of an anchor's name, which is its id, and its episode leg is BM25 with no stemming, so it is weaker evidence about an artifact than the engine's own leg. The shelf is cut to limit by that order and nothing else; no leg's rows are kept whatever they ranked.

Then the fused score is weighed by six signals. Their default weights:

[
  {"name": "exact-id", "weight": 4.0},
  {"name": "recency", "weight": 180},
  {"name": "provenance", "weight": 1.0},
  {"name": "link-count", "weight": 0.1},
  {"name": "kind", "weight": 0.6},
  {"name": "superseded", "weight": 0.5}
]

Multiplied together: an exact id match in the query; recency against a declared half-life (180 days unless the instance says otherwise), applied only to artifacts and passages — and never to a decision, constraint or assumption note, which binds until something supersedes it and is weighed as fresh as the freshest row beside it; provenance, weighing a source-of-record hit above a mirror of the same words, by a table the instance can declare; a declared-link count, which favours a linked artifact over an unlinked one but flattens out as the count grows — an inferred edge never lifts a rank; a kind weight, under which a commit counts for 0.6 of a note at the same place, because its subject shares the words of the note that holds the reasoning; and superseded, halving a row its source says a later one superseded — demoted, still served, and marked superseded_by. deterministic_facts and concepts are never reordered by any of it: ranking picks an order among the stated results, never which side of the stated/inferred boundary a result lands on.

Last, on artifacts, a decision note that revises part of an earlier one is read before it. A partial revision names the older decision by id in its body (a stated explicit-ref edge) rather than declaring supersedes, because the older record still partly holds. Where a decision note names another decision note by a stated explicit-ref or supersedes edge, its source is dated after the other's, and the read gathered both for this query, the newer note is served immediately ahead of the older one if it ranked below it. The older note keeps its row, one place lower; nothing that was not gathered is pulled in, an inferred edge moves nothing, and no row crosses the stated/inferred boundary. The move happens before the cut to limit, so a revising note gathered just past the cut is served and the last row makes room. The revising row's links already names the note it revises; no field is added.

The same order covers a decision note that changed part of a work item's criteria. A note recorded after a story, naming it by a stated explicit-ref or supersedes edge and speaking of acceptance criteria (AC5, ACs 3, "criterion", "acceptance"), is how a corpus records that one of the story's criteria was changed, widened or departed from; the story still holds, so no supersedes: was declared. Where the read gathered both, such a note is served immediately ahead of the story, and the story sinks to the row below it — the rows between move up one, so an answer ranked among them is not pushed down. A note naming a work item without speaking of its criteria is about it, not a revision of it, and moves nothing. The story sinks only when the note is inside the cut to limit, so the story stays served.

A note that declares revises: a story is the typed form of that claim. Every served row of a revised work item, on artifacts and passages and in the drafting bundle's backlog, carries revised_by — the revising notes' ids, newest first, with the criteria each names — whether or not the note itself was gathered, so a reader sees the story changed even when the note ranked nowhere. The declaration moves no row: a revised story is often still the answer to a broad question about its area, and sinking it below every note that revises it cost real searches when measured. Four module constants in retrieval govern this: REVISED_BY_MARK (on), REVISES_ORDER (off: when on, a story sinks below a gathered note recorded after it that declares it revises the story, as above but with no criterion-word condition), CRITERION_WORDS_RULE (on: the criterion-word order above), and PULL_REVISING (off: when on, a revised story in the first PULL_REVISING_TOP rows whose newest live revising note was not gathered has that note served immediately ahead of it, at most PULL_REVISING_MAX per search, its method reading revises).

Title mentions — a deterministic pass links an artifact wherever another artifact's title, or a spelling declared as its alias, appears whole inside its body and that body does not already name it by id (where it does, the explicit reference stands alone) — the same idea a notes tool calls an "unlinked mention", except this one is written as a real edge, at no provider cost. It carries method: "title-mention", ranks below an explicit reference and above anything a model inferred, and revetly audit-links counts it under its own method name beside explicit-ref and the rest. It is a link, never a concept: a title match never mints a node.

One refusal shape — a read that cannot answer serves {"refusal": {"code", "detail", "fix"}}: a stable code to branch on, what was refused, and what to call in its place. Retired tools and retired arguments answer in the same shape. There is no other error key on the read surface.

A declaration this engine will not run on — a setting that does not add up, a provider that does not resolve — comes back the same way from any tool, under the code refused, with setting naming the one it is about where it is about one, and the server keeps serving. A write tool adds wrote, which says what it cannot know: a refusal raised part-way through is not the same claim as _writing's "nothing was written", so it points at context_health rather than guessing.

The three resources answer a refusal the way the tools do. A resources/read of revetly://surface, revetly://health or revetly://brief/{artifact_id} that cannot be composed comes back as that same {"refusal": {"code": "refused", …}} payload, served as application/json whatever the resource's own media type is, and the connection stays up — so a client reading a resource never has to handle a protocol error where a tool would have handed it a sentence. No resource carries wrote; none of them writes anything.

A refusal validates against any output schema a tool declares. A tool that annotates its return — dict[str, Any], a pydantic model — has FastMCP derive an outputSchema from it, and the low-level server rejects a tool answer that declares a schema and returns no structured content. So tools/list serves every such schema as a union of the tool's own answer and the refusal envelope above, and a refusal crosses as the same {"refusal": {…}} body in structuredContent as it does in the text content. This is one rule at one seam, not a property of any tool: the retired-argument and retired-tool answers it makes before a body runs are covered by it too, and a tool that declares no schema — every tool shipped today — is unchanged on the wire.

project — on an instance declaring several projects, names which one to read as; the declared default answers when none is named, and the response says which project answered (project, or projects for a spanning read). On an instance declaring one project the key never appears. A read that answers a question (search_context, get_authoring_context, get_artifact, get_scope(question='binding'), and drift or deltas rooted at an artifact_id) reaches what the project imports, because a neighbour's decision may answer it. A listing of the project's own work (list_backlog, and drift or deltas over the whole project) reads only the items that project's own sources hold, whatever it imports. To list a neighbour's items too, name both projects. On a connection bound to one project (§1), naming none means the bound project, and naming another is refused with bound-to-another-project. deleted as an argument is the same everywhere it exists: absent excludes deleted work, 'include' reads it beside the live results marked, 'only' reads the withdrawals alone.

Stated before inferred — links, facts and statements lead with what a source or a deterministic pass stated, then what a model inferred; deterministic_links, deterministic_facts and deterministic_statements count the leading stated ones, so the boundary is one number rather than a method field to read per entry. An inferred entry whose sentence names neither artifact it joins carries unnamed_endpoints: evidence about something else until you have read where it came from, never grounding to cite.

bears_on — every statement get_scope(question='binding') serves names one member of the scope it was reached through, and one only. A statement stated inside a document has no artifact id of its own, and a document three stories cite is reached from all three: the statement is still served once, credited to the member with the shortest declared path from the scope root and, between two members the same distance away, the lower artifact id. The distance is the path and not the direction, so what the scope hangs under is as near as what hangs under it. It is a rule and not an order, which is what makes it the same on two reads of a graph nothing changed. also_bears_on beside it is a different fact: the other members a separate stated edge reached the same statement through. Two rows carrying one edge are one reach, so the member that did not get the credit is not listed there; to see which members a statement bears on without reading the whole branch, name the narrower scope and read it again. That list is in the same order the credit is decided by — nearest the scope root first, then the lower artifact id — and not the order the rows were read in, so the first name in it is the next nearest member rather than the first one a query happened to reach.

A member of the scope is never a document for this purpose. A statement stated in prose is reached by walking scope member, document, statement, and a declared parent is an edge like any other, so that walk used to reach a member's own parent and hand the rule above a nearer member than the one that stated the statement: a constraint a story stated itself read as the epic's, and where several members of one branch stated it the deepest of them appeared neither as bears_on nor under also_bears_on. The ruling is that this was never what that walk was for: a document is something the scope reaches and does not contain, and what a scope member states is reached directly and credited to the member that states it. So bears_on names a member that states the statement or reaches it through something outside the scope, and never a member that only sits above one that does.

A statement is gathered by what it is, not by which path reaches it. Both walks, the one that reads what a member states and the one that crosses a document the member reaches, admit a node by the same rule: a source typed it as one of gathered_from.document_types, or it is shelved under one of gathered_from.entity_types. The walk through documents used to read the entity types alone, so a decision note typed by doc_type and shelved under no entity type (every decision note on a corpus ingested before those types existed) was served when a member stated it and dropped when a member reached it through a note that cites it, and the member that reached it that way was named nowhere. A decision note a member cites is served twice over, as two different nodes: as a statement in its own right, and as a document whose own statements are reached through it.

And a statement is reached only through the member itself or a document the member touches: a conversation note, a decision note, a reference, a delivery record: a node whose kind is doc or delivery-record, or an artifact with a doc_type ingested before nodes carried a kind. A kind nobody declared is not a document to this walk, whatever doc_type it carries, so a connector's new kind is not a step until it is added. A sibling work item is never the step between, even though a declared parent makes it adjacent to the scope: what a sibling cites is its own grounding, and a decision that binds the whole branch is stated or cited by the parent, which the scope already reaches. Neither is a revision, a commit, a comment, or a concept a model extracted. A document split across two nodes counts as one document, so a statement hanging off either of them is reached.

inferred_cap — order is not the whole of that promise. On a rich corpus the stated half leads the answer and the inferred half still wins by volume, so search_context fits its two inferred shelves by count: concepts, and the facts past deterministic_facts. inferred_cap on the answer names how many rows of each were served and how many sentences of each concept's summary — the paraphrase a model accretes a clause at a time, whose whole length is summary_sentences_total wherever it was cut. What the cap removed is reported like every other cut, under truncated_concepts and truncated_facts with the reason inferred-cap beside the read-budget one. Nothing stated is ever cut to make room for something inferred: over budget, the inferred rows go first, then the surviving concepts' sentences, and only then the passages, the stated facts and the artifacts. inferred='all' widens it — the whole inferred half, under the read budget alone — and any other value is refused by name. get_authoring_context's related is a search_context over the topic and arrives under the same cap carrying the same reports.

freshness — the fixed stamp every served read carries where the caller reaches a project at all: when the oldest source was last read, and whether watch is running. A timestamp, not a warning — an answer's age is part of the answer.

grant — present only for a caller whose grant reaches no project of this instance. It names the claim that token presented, the values it carried, and either that no [auth.grants] entry here names them or that one does and reaches nothing declared. It names no project, no count and no roster: it is a fact about your own token, and the empty results beside it are that fact rather than a statement about what this instance holds. Without it, "my grant reaches nothing", "this project holds nothing", "ingest never ran" and "the engine is broken" arrive as the same empty list. revetly doctor --token <bearer> reports the same before a client is pointed at the endpoint.

context_health — present only when the last read pass failed. What you are reading is what the corpus held before that pass, so an artifact that was committed since may be absent. "Nothing matches that" and "the pass that would have extracted it died" are indistinguishable from the results alone.

history — present where a source has a floor, naming how far back each was actually read, as one date per source:

{"floors": {"commits/acme-app": "2026-07-06", "todoist": "2025-01-01"},
 "gaps": {"commits/acme-app": ["2026-08-01/2026-08-30"]},
 "partial": ["todoist"]}

Below a floor nothing has been read, so an empty answer about anything older is an unread past rather than an empty one. Say which; never present the second as the first. gaps names any stretch above a floor that is unread too (a span a connection left unread, or one a raised floor put out of view), as start/end dates. partial lists the sources whose backfill stopped with windows still to read. No read here reaches further back unasked: revetly backfill --dry-run, run by the operator, prices it. context_health serves the whole per-source rows under its own history.

freshness, history and context_health describe the project the read named. Each is read off that project's own sources, so a read naming one project carries none of another's floors, even where it may read the other. Naming none, they are the default project's (or, for a caller a grant narrows, the granted project's). A read spanning several projects, by naming them or by a grant that names several, carries each one's under its name: {"history": {"engine": {"floors": …}, "pigent": {"floors": …}}}, and freshness and context_health the same way.

It is the floor on every read, get_artifact included. An artifact's own revisions, comments and transitions are a different fact under a different key — history_entries, with history_total and history_next beside it — so an artifact that has history carries both, and neither is dropped to make room for the other. A client reading history is always reading the floor.

withheld — artifacts this instance declares never-extracted. They were read and committed and their bodies reached no LLM call, so nothing can be searched inside them. They are reported present-and-withheld rather than omitted, because an artifact silently missing from a search is worse than one that says why it cannot answer. Asked for by id, one answers in_context: false from the ledger — and carries the same project attribution every other answer carries, so a refusal on a multi-project instance says which project's withheld artifact it was.

deleted — the source no longer has this artifact. Deleted work is out of list_backlog, search_context and get_authoring_context by default: an agent grounding before work is never handed a work item that no longer exists. It is reachable on purpose — deleted='include' beside the live results, deleted='only' for the withdrawals alone, which is how "what was deleted, and when" is answered out of CONTEXT rather than out of git. Anything that comes back carries deleted: true and deleted_at, and links into deleted neighbours carry other_deleted, so a client can never render one as live.

deleted_at carries deleted_clock, which says which of two cases it is. source-declared: the source dated the removal itself — a repo-backed source names the commit that deleted the file — so the withdrawal is on the source clock like any other change, and a get_scope(question='deltas') window holding that commit holds the deletion. found: the first complete read of the source that came back without the artifact, which is what most sources leave available, because they delete without saying when. A date carrying no clock is a found one and is never read as declared. Where the date is absent, the artifact was read as gone before this instance recorded times: that is unknown, never recent, and never today. Fetching a deleted artifact by id still answers with its record, links and all: a deletion closes an artifact out, it does not erase what it said.

Every write tool returns before extraction finishes: files are written and committed — that half is durable — while extraction runs in the background. Poll context_health with the returned handle as capture before a read that expects the new artifact. Pending is not failed.

And a handle is not addressed to one server. The ledger it is filed in belongs to the process that filed it, so a handle made through one server and polled through another — a stdio session beside the long-lived HTTP endpoint, which is this deployment's ordinary shape — comes back pending with pending_elsewhere: true and who is running the pass. unknown means all three at once: no ledger holds it, no finished pass covers it, and none is in flight. It is the answer that says stop waiting, so it is not given to a capture that is minutes from landing.

6. Which clients this was proven with, and when

Everything above is what the specification requires, and any client that meets it is served by the same code as any other — there is no client-specific branch in this engine, so a client nobody has tested is not a client this engine gives a second code path.

What follows is narrower and is the only claim made about particular software: these clients were connected to a served instance, on these versions, on these dates, and the gate each was given was read out of the engine's own client declared: line (§3) rather than out of anybody's documentation. A row is a checked claim with a date on it. Nothing here expires on a schedule — a host can start or stop painting a gate in a release nobody here controls, so read the date and judge it, rather than trusting a table because it has not been marked stale.

client version proven protocol gate over HTTP gate grade registers itself
openai-mcp (ChatGPT) 1.0.0 2026-08-23 2025-11-25 app verified; enforcement a host convention — url-capable clients get the URL gate, enforced + verified yes, RFC 7591 at chatgpt.com
Anthropic/ClaudeAI (Claude desktop app) 1.0.0 2026-08-24 2025-11-25 app verified; enforcement a host convention — url-capable clients get the URL gate, enforced + verified yes, RFC 7591 at claude.ai
Visual Studio Code 1.128.0 2026-08-24 2025-11-25 app verified; enforcement a host convention — url-capable clients get the URL gate, enforced + verified no — it takes a client id
claude-code (terminal CLI) 2.1.239 2026-08-24 2025-11-25 elicitation enforced + verified no — it takes a client id

Whether each one refreshes its tool list is a separate checked claim, and on 2026-08-27 exactly one of them was measured against the declaration and the notification described in §1.

openai-mcp (ChatGPT) honours neither. Five sessions on one connector, across two chats, each one opening a standalone GET /mcp stream and each one sent notifications/tools/list_changed on it: zero tools/list requests reached the engine, in those sessions or in any session after. It serves its model from a catalogue cached against the server URL, and the only refresh observed to work on it is a connector added under a URL that differs — a query string is enough. Nothing about the calls themselves was affected: all five returned normally.

The other three rows are unmeasured on this point, which is not a claim that they are better.

What each one needed pasted is revetly doctor's to hand you rather than yours to derive: it prints one connect line per client above, carrying the address this instance declares, what that client needs declared here, and the gate it will get. What it will not print is a line for a client that is not in the table, because that would be a guess.

Two answers a client needs from the instance, and each fails its own way. [auth] registration_hosts is where a client that registers itself may be — its redirect URIs' hosts. A client whose host is not declared has its registration refused by the authorization server's own policy, naming that policy rather than anything an operator wrote. [served] allowed_origins is where a client that runs in a page may call from; one that is not declared connects and then loses its session from its second call onward, which looks exactly like a client that connected.

A terminal client needs neither. Its OAuth redirect is on loopback, which an allowlist written for hosted chats refuses — and it does not have to register at all, because revetly init --auth already scaffolds revetly-client with loopback redirects and the CLI takes --client-id.

7. What a client must never do