Manual 0.8.0

Engine config surface & local-only assumptions

Who this is for: whoever sets an instance up and whoever has to explain what it is currently doing. You need: an instance root with a revetly.toml and a .env — revetly init writes both. Not here: what a connector or an adapter does with its own table (writing-a-connector.md, writing-a-write-adapter.md), and how several projects inherit these keys (multi-project.md).

Two halves: the generated table below is the reference — every option, the plain name a person reads on the page beside the real name this document uses, its type, what it falls back to when nothing declares it, the values it accepts, and what it is for, short sentence and long. Every one of them is also settable from a page on this machine — revetly config, which renders its controls from the same declaration this table is generated from and writes these two files rather than a store of its own. Under it, the sections, with an example each is the same surface as something to copy, and two complete files puts it together — those two are hand-written prose, so read the table above as the contract and them as illustration.

If you have never seen this file, do not start here. Open revetly config first. It asks for the one or two answers this instance cannot run without and holds everything else behind a disclosure until you go looking, which is a different document from this one and deliberately so. The page these are set from is what it shows and why it shows so little of what is below — including the three facts it can tell you that this table cannot: a folder this engine cannot reach from where it runs, what the first pass will cost before it runs, and that a repository is read forward from the moment it is connected, so its past is a separately priced backfill rather than something a first run quietly includes.

The page these are set from

Four levels, and what each is for

The page is organised by four subjects now, not three: this instance, the projects in it, everything the instance itself holds and does not delegate to any project, and the whole declaration underneath all of that. Each level shows less than the one below it, and nothing is reachable only past the last one — a setting no card or section claims is still in All settings.

This instance is two lines and nothing else. Neo4j: connected (or not reachable, or not tested) opens the three storage fields behind Change; LLM and embedding: {provider} — or, where the two differ, LLM: {llm} · Embedding: {embedding} — opens both provider selectors and both API keys behind its own Change. Neither line paints a field until Change is pressed, and the card carries nothing beyond these two lines: the machine-shaped settings that used to sit in the instance card's own Advanced — which container Revetly runs in, which one restarts it, where that reaches Docker, the two ports — moved to Ports, updates and containers, one of the seven sections a level down, and the instance card has no More and no Advanced control left to hold them in.

Projects is one card per project, holding seven bands in this order: Namespace and identity, Sources, Records folders, Approved item store, Item types and parents, Imports and exports, Overrides. A band draws its own name only where it holds three rows or more in the state being painted; Sources and Overrides are the two exceptions and always draw theirs, because each is a gesture and a count with nothing else to sit under. A source is a line or a card. Where its only setting is one already-spoken-for line — Documents: docs, adr for the docs connector, which has exactly one field — it is a line carrying Change, Test connection and Remove source; where it carries rows of its own, the commits connector's repository floor among them, it stays a card, nested exactly as it always did. Sources is the editor of [connectors] enabled, and there is no text box over that list anywhere on the page: Add source offers the external connectors this engine registers that the project has not already enabled, Remove source takes one off, and Revetly's own records are neither offered nor removable — they are the Records folders line above the sources, never one of them. On a multi-project instance Add source asks which connection for a connector that declares connection keys: the instance's, which writes this project's scope keys and not one connection key, or this project's own, which paints the connection fields too with the instance's values as their grey. The source's line then says which of the two it runs on, and Test connection carries the project it is about so the probe is answered about that project's chain. Overrides lists what a project restates of the instance's own settings, each as here <value> · instance <value> with Remove override, and reads No overrides where it restates nothing. Add override sits behind the Advanced settings switch and offers exactly five sections: Extraction and spend, Sensitivity and withholding, Watch and retries, Git and records, and Document folders. It never offers LLM and embedding — the loader refuses [projects.<key>.llm] and [projects.<key>.embedding] outright — never Authentication and approval or Ports, updates and containers, which are read once for the whole instance rather than per project, and never a component table: a project that already restates [connectors.<name>] for one of its sources keeps rendering it where it always has, on that source's own card, with a sentence naming that a restated table replaces the instance's rather than merging into it — its connection keys apart, which a project stating none of its own inherits — and this page does not mint one blind. Changing a restated value is removing it and adding it again, or reaching for All settings; the band is a reading, not a box. A shared namespace ([shared.<key>], a third namespace beside the projects that several of them resolve into) is not a project and draws no project card — where one is declared it is its own named line at the Projects level, <key> · read by <exports>, with Change opening its two rows.

Revetly's own records are not a source. The backlog it writes, the discussions it captures and the delivery records it stamps — pm, conversations and deliveries — are the Records folders line on the project card, and they are never offered among the sources to add: connecting an instance to itself is not something to offer. Which sources those are is one declared boolean on the connector's own SourceDeclaration (own_records, false unless a connector says otherwise) and never a list of three names inside the page. file_backed cannot draw that line and never could — docs is file-backed and reads somebody else's documentation, and it stays somebody else's the day it comes out of a drive service rather than a folder.

Instance settings is the level this story adds. It draws up to seven sections, in this fixed order: LLM and embedding, Extraction and spend, Sensitivity and withholding, Watch and retries, Git and records, Authentication and approval, Ports, updates and containers — plus two more that draw only where the file declares two or more projects: Connections, between Git and records and Authentication and approval, and Project defaults at the end. Connections is one line per external tool the instance holds a [connectors.<name>] table for, reading who it connects as and where (Todoist: token set · api.todoist.com), each with Test connection and a Change opening that connector's connection keys and only those, plus Add connection offering the registered external connectors that declare connection keys and have no table yet. An instance-level table's SCOPE keys are not drawn there — they are the single-project shape left behind, they reach only the projects that declare no table of their own, and revetly doctor fails each of those projects. Each heading carries the count of rows the section is showing in its current state; a section with nothing to show in normal mode — Ports, updates and containers has no basic-tier row at all — is not drawn until the Advanced settings switch is on, and there is no placeholder standing in for it while it is absent. Rows follow the order the engine declares for their section, never registry order, and a dependent row is indented under the row it depends on and drawn immediately after it — which is what keeps First retry delay beside Read pass retries rather than forty rows away in whatever order the file happens to walk. One search box at this level matches a label or a summary, opens every section that matched, and dims the rest.

All settings keeps the role it has always had: generated by walking the engine's own registries rather than by collecting what a card or a section drew, so a row nothing above claims is still here, grouped by the three subjects with a project's own restated rows under that project. It gained two filters, Changed from default and Required, beside its own search. It remains the one place a real name — an environment variable, a TOML section and key — appears at all; every level above names a setting only by its label.

The five reveal rules

Everything above follows from five rules, and nothing on the page departs from them:

  1. A required field that is answered is one line carrying Test and Change; its inputs stay behind Change, and first paint shows lines, never fields. Once both halves are answered, the two provider selectors fold into their line the same way. A line whose last verdict was a pass is green and carries the checker's own sentence — key accepted, Revetly can read this folder — as a line under the reading; with no verdict on file it reads exactly as it always did.
  2. A section shows its basic-tier rows only, its heading carries the count, and a section with nothing to show in normal mode is absent entirely until the switch is on — never a heading with nothing under it, because a heading saying there is nothing here is noise on a page whose whole job is removing noise.
  3. One Advanced settings switch reveals every advanced-tier row in place, under a thin rule, inside whichever section or card its declared topic names, and draws the sections normal mode leaves out entirely. No card carries an Advanced control of its own. Show N more stays per card and reveals only that card's own basic rows.
  4. A dependent row exists only while its parent enables it, indented under that parent, and appears or disappears the moment the parent's value changes — before Save, with no reload.
  5. One search box, at Instance settings, matches a label or a summary and opens the sections that matched while dimming the rest. All settings keeps its own search and its own two filters, over the technical names, apart from this one.

The Advanced settings switch

One control, in the sticky masthead, and there is no second one anywhere — no per-card Advanced button exists any more. It is remembered per browser and written to no file: turning it on changes nothing Revetly will read at its next start, and somebody opening the same page in their own browser sees it exactly as they last left it, which may not be as this one leaves it. Its state paints before the first pixel, so there is no flash of the wrong count, and flipping it does not reload the page — every advanced row already rendered is simply shown or hidden in place, and every section's heading swaps between its basic count and its basic-plus-advanced count with it.

/start has retired

There is no first-run route any more. /start answers a redirect to the front door, at the step that replaced its first one — a redirect and never a 404, because that address was printed here and somebody has it in a bookmark. What it was actually for is one gesture, and that is what it is now: Add your first project, entered from the projects list, five steps in the same order — name it, say where it lives, connect the sources it reads, price the first pass, run it. It is a query on the front door rather than a surface of its own, and it can be abandoned at any step without writing anything.

The acts are unchanged. Pricing reads the sources already in hand and calls no provider; running refuses without the quote it showed you, and runs under this instance's ingest lock; the price is broken down per connector and names the models no rate table could price rather than reporting them free. A folder the running engine cannot open is still refused before it is written — now in a sentence naming who can grant that folder, rather than in the compose fragment they would have to type.

Adding the first project edits the project revetly init wrote. It never adds a second one beside it. A folder of its own is the one setting the bare [project] table cannot hold — root is declared only under [projects.<key>] — so a first project that lives anywhere other than the instance root is declared under its own table and the bare one is emptied in the same write.

Adding a later project migrates [project] into [projects.<key>] in the same save, under the name that project already answers to, with the root it already read and the two identity keys that would otherwise be inherited by both projects at once. That migration is not tidiness. The moment any [projects.*] table exists the bare [project] table stops being a project, so a second project declared beside an un-migrated first one orphans everything already read under the first one's namespace — no refusal, no warning, and nothing to notice until a search comes back empty. Two projects that would carry the same namespace are refused before either is written, and the save that makes a second project declares [instance] default_project where nothing has.

One name is asked for. The namespace a project's artifacts carry is derived from it by the loader's own rule, shown as its own line rather than hidden, and separately editable under Advanced — a project has two names, and the moment those two silently differ nobody can answer where a saved artifact came from.

What decides where a setting appears

Four facts, stated on the option itself, answer four different questions.

tier — required, basic or advanced — decides grouping and nothing else: which of the page's disclosures a row sits behind. topic decides which section or band it sits in — one of the seven instance sections, project-defaults, or one of the project card's own bands — while subject still says whose the setting is: instance, project or source. A row on a project card whose topic names an instance section is there because that project restates it, and it draws in Overrides rather than in one of the six bands, which is the whole rule Overrides is read by, rather than a list anybody keeps in step. order decides the position within wherever topic placed a row — required rows first, then a topic's basic rows, then its advanced ones, with a dependent immediately after the row it depends on. shown_when names the one other option, and the condition on its value, a dependent row exists under. Every declared condition reduces to one of five predicates: the parent is answered (records remote and branch, only once a records clone is declared); the parent's value is one of a stated few (the verifier table, only under the method it names); the parent differs from another named option (the embedding endpoint and its siblings, only where the embedding provider is not the LLM provider); another section is declared at all (the updater rows, only where [updater] exists); and the file declares at least a stated number of projects (Project defaults, and the default-project selector inside it, only at two or more). A row states at most one of the five, and it is re-evaluated in the browser on every change to its parent, which is what makes the fourth reveal rule true before Save rather than after a reload.

tier is deliberately not the required flag beside it in the same record: that one is the loader's, and means a value must be there. A declaration stating both and making them disagree hides a field that cannot be left empty, and is refused by name.

Whether a field is blocking is still asked at render time, of the provider or connector that owns it, and never read off tier — the same field is optional on one provider and refused-without on another, and no static flag says both. Measured against the providers this engine ships:

A field can become needed because a vendor said so. That is the second of the two sources, and it runs. It was built, tested and never fired for one release — the argument that carries a probe's verdict had no caller anywhere in the engine — so a page repaired against an earlier version of this document ran the loader-derived half alone. Pressing Test asks the provider or the connector, and what comes back is kept: the last verdict per probe, in .revetly/config-probes.json beside the staged record, written by the probe route and read when the page is next drawn. A verdict marks the settings its own repair sentence NAMES — [llm] workspace, [connectors.commits] repos, an environment variable — and only where the engine or a component actually declares that setting; a sentence naming nothing marks nothing, and the page invents no verdict of its own. A marked field is drawn in the open with the author's sentence under it in red, whatever tier it carries.

How many calls a Test makes. One on the provider card, usually: the free model lookup, which is a GET at the declared endpoint under the declared key and carries no body. Two where the declared model does not resolve and the provider is one whose probe lists — openai-compatible, openrouter and cloudflare, the three that pin no model — because a 404 on the model route says nothing on an endpoint that may not serve that route, so the catalogue is asked to settle it. That second call is a model listing, and it is the one revetly doctor prints as egress (model listing) and context_health carries under egress.listing; providers.md is where both are explained. So a press on one of those three providers, on an instance whose declared model does not resolve, reaches the vendor twice. Neither call is billed, and neither sends a body of anybody's work.

When a verdict stops being true. Each entry carries a digest of the values it was about — the declaration the probe read and the variables that declaration names, secrets included, which is why it is a digest and not a listing: nothing readable as a credential is written to the file. Replace the key, move the endpoint, change the model, swap the provider, and the digest disagrees: the verdict is ignored where it is read and dropped the next time anything is written. A pass replaces the failure before it under the same handle. It survives a reload and a restart — which is the one place it deliberately differs from the staged record beside it, where a row is pending until a restart and a restart therefore finishes it; a vendor's refusal of a key is as true after a restart as before it. Nothing re-probes when the page is drawn: a Test is still something a person presses, and the first paint fetches nothing.

Test connection on a source probes that source. Its declared folders, the settings of its own nobody read, and its own doctor_checks — and nothing else. It used to run the instance-wide sweep, so pressing it on a folder of documents answered about a tracker's token, a git remote and the write adapter, and reported 16 source check(s). No check on this page answers with a count now: a toast carries what was found in the words of whoever found it, or one sentence saying nothing needs anybody. The findings land on that source's own line — green with the checker's own sentence, or red with the fix, which marks the setting it names the same way a provider's does. The instance-wide sweep stays where Test what this can read uses it, in the Add-a-project step, and two checks stay on it alone: the git credentials, because the repositories they are about always include the instance's own root and its records repo, and the write adapter, which is where Revetly writes rather than what it reads.

The same rule read the other way is Todoist, which declares neither project nor projects as required, refuses the two of them declared together, and reads every board in the account when neither is named. So the page must not mark either required. What a connector requires it declares; what it needs beyond that only its own check knows, and a check is what Test is for.

Of the hundred and fifteen options the engine declares, the ones with subject = instance split 7 required / 11 basic / 64 advanced, a project's own 23 split 3 / 12 / 8, and a source's 10 split 2 / 3 / 5. The levels moved three rows when they were drawn: [docs] roots to required, and [egress] withhold_above and [egress] library_telemetry to basic. In normal mode, on a connected single-project instance, Instance settings draws at most 15 rows across at most 6 headings; the switch adds the rest, each under a heading that says what it changes.

summary stays the one sentence a setting is introduced by, with detail — most of what that sentence used to carry — behind a per-field toggle; the table below carries both joined, because this document is read by somebody repairing rather than by somebody setting up. A closed set's values still render as the word the option declares for them rather than as the token — the Accepts column below carries both, because this is where the two vocabularies meet, and the loader still validates the token underneath. The standard both are written to, the fifteen-word limit a summary is warned against, and the glossary every label and word draws from, is its own page: writing.md.

What the page will not say

No file name, no environment variable, no configuration section and no shell command appears on a card. Every field is introduced by its label, the plain name a person reads, which is almost never the key.

All settings is the one place a real name appears. It stays reachable at the foot of the page, opened by Show rather than by the Advanced settings switch, still generated by walking the engine's own registries rather than by collecting what the cards drew — so an option no card claims is still there — and it is grouped by the three subjects, with a project's own restated rows under that project rather than as a fourth group. Each row carries its plain label and, beneath it, the name this document uses; the search matches either, or the summary, for somebody who knows what a setting does and neither of its names.

Where a value came from is stated only when it would surprise — six cases now, and every one of them is a case where changing something here would do nothing, would do more than the one card it sits on suggests, or has already happened:

The rest of the time the page says nothing, because where a value came from is not interesting when changing it works.

Unset and defaulted are told apart by the control, never by a label. Grey writing inside a box is the default you get by leaving the field alone, and it is never a format hint. A red, empty box is something only you can answer, with nothing sensible to stand in. A model or an address takes its grey from the provider actually in force rather than from the option's own default, which is empty on every one of them.

Places on this machine are picked, not typed

Seven of the settings a card asks for name a folder on the machine the engine runs on: a project's folder, the repositories it reads, the folders its prose lives in, where new items, conversation notes and delivery records are filed, and the records store. Every one of them is browsed rather than typed. The engine serves that listing itself, at one route with one verb, and it lists directory names and nothing else — it creates no folder, renames nothing, deletes nothing, moves nothing and opens no file, not even to decide whether to show one. Anything listed is something this process can already read, which is the whole point: a browser's own folder chooser withholds the real path from a page, and where the engine runs boxed off it would happily offer a folder the engine was never given, save it, and leave the mistake to be found a pass later. Browsing starts where the field points and may go up and out of it; what it may not leave is what the running engine was actually given — the disk on a host install, and the mounts plus the instance root under the container transport.

At least five more settings point at this machine and are reachable only under All settings: [records] conversation and [records] delivery, [engine] source, REVETLY_DOCKER_SOCKET, and REVETLY_ROOT, which is read-only. They use the same picker where they are opened.

Three settings keep the typed field, deliberately. [llm] module, [embedding] module and a component's own module each name a Python file the loader imports and executes. Browsing to an arbitrary .py the engine will then run is a code-execution gesture on a settings page, added for the one audience that will never need it — whoever writes a provider or a connector is editing revetly.toml already and knows where they put the file. Those three stay as text, under Advanced.

A change that only takes effect at the next start

Nothing is written until Save, and there is one Save for the whole page however many cards were touched: every change at once, or none of them. A save the loader would refuse writes nothing, says why in the words the page uses, and marks the field it is about — the same check Revetly makes when it starts, made before the file is touched rather than after.

Most of what this page writes is read when Revetly starts rather than when it is saved, and a save that lands in that class raises a banner. The banner has two shapes, and which one you get is decided by what this instance is running and by nothing anybody sets on this page:

The banner's other action is Put it back, which saves the previous value again. It is an ordinary save and not an undo — nothing already read is unread by it — and there is no restore route here, nor one implied.

It opens on an instance Revetly will not start on

The page is the answer to your instance will not start, go and fix it, so it opens on the instance it exists to repair. A declaration Revetly refuses — a model an endpoint pins none of and nobody named, a place holder nothing fills, a searching half never declared, a value outside the set its setting accepts — is a banner at the top of the page carrying the refusal in the same words Revetly will print the next time it starts, and naming the setting where it named one. It is not a page that will not open.

What it can still do there, it does. Every setting is under All settings and every one of them is editable, the ones nothing can run without are marked, and the page still knows where it listens and who may reach it, because those are values the file states rather than values Revetly works out from it. Where the file does not say, the page falls back to the same default it always would and tells you which — a page listening somewhere other than where you expect it is not something to discover.

What it cannot do, it says it cannot do rather than offering a control that could only fail. Projects are not drawn: each of them is one project as Revetly loads it, and this is precisely what Revetly will not do — every setting they hold is still under All settings, and they come back the moment the file loads. Nothing offers Test, because there is nothing resolved to check with. A field that lists what your provider actually runs says it cannot ask yet. There is no Add project, because adding one prices and runs a pass. And there is no restart, because restarting into a declaration Revetly will not start on takes the instance down and brings nothing back.

The way out is one save. Saving goes through exactly the check Revetly makes when it starts, so a save that does not fix the refusal writes nothing at all — you get the next problem, in the same words, with nothing half-written behind you. A save that does fix it is written, and the page picks itself up on the spot: the banner clears, the projects come back, and nothing was restarted. revetly doctor names this page as the other way out of a declaration it will not accept.

It behaves the same whether the file was already broken when the page opened or broke underneath it while the page was open — one page, one banner, and the order stops mattering. The one case still out of scope is an instance with no revetly.toml at all; that is what revetly init is for.

Opening it is one gesture

revetly config prints one address, and that address reaches the page. It carries the page's own key, you open it as it is, and there is nothing to look up and nothing to paste in beside it. Under the container transport that is the whole of it — the command runs inside the engine and the browser is on your machine, so it says plainly that opening the address is your move. On a host install where there is a browser to reach, revetly config --open opens it for you as well as printing it; it is off unless you ask, because a command reaching out to drive your desktop is not something to do on somebody's behalf, and the address on the terminal already opens it.

The key does not stay in the address. The answer to that one request signs the browser in, and the page takes the key back out of the address bar in place before anything is painted — no reload, no second request, and no entry left in history to go back to. What is left is the plain address, and it is worth bookmarking: it still opens the page. The key is never written into a link, a form or anything else on the page, and no request the page makes carries it.

Being signed in lasts a month. Come back inside it, in the same browser, and the bookmark opens straight into the page. Come back after it, or in a different browser, and you get a short page saying to run revetly config again and open what it prints — the same one gesture, and never a file to go and read.

The key stays. Reaching the port and being allowed to change what this instance runs on are two different questions, and the key is what separates them. [config] expose = "loopback" asks for the page to be published on this host, which is not the same as only you: a shared machine, a devcontainer, an SSH tunnel somebody else opened and any other process running as any user on that host all reach a loopback port — and this page rewrites the file Revetly starts on and can ask for a restart. There is deliberately no declaration that turns the key off. One was considered and not built: [config] expose is a claim about what the compose file publishes rather than a fact about the socket (the page binds every interface inside its container, by design), it resolves to loopback when the file says nothing at all, and an override compose file or a plain docker run -p moves the real mapping somewhere this engine cannot see from inside the container it runs in. A key that could be declared away on the strength of a claim nothing can check is not a key.

What [config] expose is, and what it is not. It is a declaration that a compose file is meant to carry out, written into the ports: line by revetly init. It is not a property of the socket: the page binds every interface inside its container whatever the line says, because a process bound to 127.0.0.1 inside its own network namespace is one no published port can reach. And it is not something the engine enforces or can confirm. docker compose merges a docker-compose.override.yml without being asked, and -f, COMPOSE_FILE and a plain docker run -p each move the mapping without any file in the instance root changing, so what a container can read is what somebody wrote down rather than what the daemon is doing.

So the engine says which of the two it has. Where it measured the binding it says what it observed and where from: the address this process bound, on a host install where nothing publishes a port in front of it, or the Docker daemon's own answer where somebody mounted the socket into the engine (the compose file this engine writes deliberately does not, so that is normally "no socket here", said in those words). Everywhere else the reach reads this host only, declared and not confirmed and the sentence under it names the file that asks for it and every way the mapping moves without that file. Where two files in the root publish the page's port on two addresses, the config page line warns and names both, because which one is in force depends on an invocation the engine never saw.

A word outside loopback and all is refused by the loader rather than read as the safe one, which is how this engine treats every value outside a closed set. An absent [config] table and an empty value are both the default, and both are reported as the default rather than as something somebody wrote.

revetly doctor's config page line says the same three facts this command does — the port, who can reach it and on what evidence, and that a key is in force. Both read them from one place, so the two cannot drift into two claims about one page.

How the page itself runs

Three facts about how the page runs that are worth knowing before the first edit. It is a process inside the engine container, started and restarted by revetly supervise, the image's command, so a restart of the engine takes the page down with it and the new container brings it back, whether the restart was made from the page or by hand. An engine service that declares a command of its own starts no supervisor, and nothing starts the page there. The page shows and tests the values the engine will start on next: what the .env declares outranks what the process happens to hold, so a key you just fixed is the key Test judges — and a value the compose file pins as a literal on the engine service is shown as such and is not editable here, because an edit to the .env under that name would never take effect. And restarting is the update endpoint's authority: the page asks for that endpoint's token at the moment of the restart and never carries it itself; revetly config prints the token beside the page's own address where the instance runs an endpoint.

One image, and no host defaults

The engine ships as one data-free container image that must boot unchanged as both a Tier 1 instance (user runs it on their machine) and a Tier 2 instance (Revetly runs it per-tenant on infra). Every runtime input is read strictly from the config surface below — env vars, injected secrets, and the instance revetly.toml/.env — with no hardcoded or host-derived fallback. Missing or malformed required config fails fast with a named fix; it never silently falls back to a host default.

Config surface (declared inputs)

Every option below is generated from revetly.configspec, which is what the engine itself reads: the type, the default, the values it accepts, the plain name a person is shown, where the page puts it and both halves of the sentence are one declaration, and the loader builds a project's configuration from that same declaration — so this table, the page at revetly config and the value the engine runs on cannot say three different answers for one key. Name is what the page calls it and Option is what this document calls it — the two names All settings prints one above the other, and the search there matches either. Regenerate the block with revetly config --write-reference.

Environment inputs

Option Name Section Type Default Accepts Shown when What it is
NEO4J_URI Neo4j address Storage str required — no fallback The address the graph is read and written at. There is no local fallback: unset, Revetly refuses to start rather than reaching for a host default and writing somewhere nobody meant.
NEO4J_USER Neo4j user Storage str required — no fallback The user name Revetly connects to the graph as.
NEO4J_PASSWORD Neo4j password Storage str required — no fallback The password for that graph user. No fallback. Revetly refuses to start rather than guessing one.
OPENAI_API_KEY OpenAI API key LLM and embedding str none The API key that pays for OpenAI LLM and embedding calls. OpenAI reads and searches on this one key, and it is what an instance that has named no other provider runs on. The page and the doctor say whether it is needed from the providers actually in force, never from this line alone.
ANTHROPIC_API_KEY Anthropic API key LLM and embedding str none The API key that pays for Anthropic LLM calls. Anthropic serves no embeddings, so an instance running extraction on it needs a second provider and a second key beside this one.
REVETLY_LLM_MODEL LLM model override LLM and embedding str gpt-4.1-mini An LLM model set in the environment, outranking the model set here. Unset, this page's answer decides, and then Revetly's own pin for the provider in force rather than whatever ambient default the client library carries.
REVETLY_LLM_CONCURRENCY LLM calls in flight LLM and embedding int (calls in flight) 4 How many LLM calls run at once; four by default. Raising it does not make a long read pass shorter — it is one episode's calls, not the pass's. Match it to what the provider allows at once.
REVETLY_LLM_MAX_ATTEMPTS LLM retry attempts LLM and embedding int (attempts) 5 How many attempts a throttled LLM call gets before the read pass fails.
REVETLY_GIT_TOKEN Default git token Git and records str none The fallback token for any git credential naming no variable of its own. A token typed into the configuration file is refused by name, because that file is committed.
REVETLY_GROUP_ID Namespace override Namespace and identity str none A project namespace set in the environment, refused above one project. It outranks the namespace declared for the project, and is refused outright where more than one project is declared — one pinned namespace cannot answer for two.
REVETLY_CAPTURE_INGEST Extraction timing override Watch and retries str none background (Run in background), sync (Wait for extraction) Whether a save waits for extraction, set in the environment. Run in background returns as soon as the files are written and extracts after; wait for extraction holds the call open until extraction has landed. Unset, the instance's own extraction timing answers.
REVETLY_REVIEWER Terminal approver name Authentication and approval str none The name recorded as approver when a review is answered at a terminal. On an endpoint people sign in to, the signed-in caller wins and this is not consulted at all.
REVETLY_AUTOCOMMIT Commit on save Git and records bool true Whether Revetly commits the files a save writes. Off opts out of the commit alone: the files are written either way, and committing them is left to whoever runs the instance.
REVETLY_REVIEW_PAGE_LINES Review lines per page Authentication and approval int (lines) 20 How many lines of a draft are shown at a time; twenty by default.
REVETLY_ENGINE_CONTAINER Engine container Ports, updates and containers str none [updater] is declared The Docker container restarted when an update or a change is applied.
REVETLY_UPDATER_CONTAINER Updater container Ports, updates and containers str none [updater] is declared The Docker container holding the service that restarts the engine.
REVETLY_DOCKER_SOCKET Docker socket Ports, updates and containers str /var/run/docker.sock [updater] is declared The socket path the updater reaches Docker at.
REVETLY_ROOT Instance root Ports, updates and containers str none The folder this instance's settings are read from. Otherwise discovered by walking up from the working directory for a configuration file.
REVETLY_IMAGE Engine image Ports, updates and containers str none The build this instance is pinned to.

revetly.toml

Option Name Section Type Default Accepts Shown when What it is
[auth] mode Authentication mode Authentication and approval str oidc oidc (Sign-in at an issuer), static (A token issued here) Which credential a served endpoint requires. Sign-in at an issuer is what every declaration written before this key existed meant. The whole section is read on the served HTTP route only.
[auth] issuer Issuer Authentication and approval str empty [auth] mode is oidc The authorization server this instance trusts to sign people in. Published, so a client discovers where to authenticate rather than being told out of band. Required under sign-in mode.
[auth] resource Resource identifier Authentication and approval str empty The name a client asks for a token for. Declared, never derived from the bind address. Required in both modes.
[auth] audience Token audience Authentication and approval str empty What a token must name, where that differs from the resource identifier. For an issuer that writes something other than the resource identifier. Empty means the resource identifier.
[auth] groups_claim Groups claim Authentication and approval str groups Which token claim carries the groups a caller is in; groups by default. It matters only where grants are declared.
[auth] verifier Token verification method Authentication and approval str empty the token verifiers this instance registers — introspection (Ask the issuer), jwt (Check the signature), static (Match a local token) [auth] mode is oidc What turns a presented token into a caller. Check the signature verifies the token's own signature against the issuer's key set; ask the issuer calls its introspection endpoint on every request; match a local token compares it against a token issued here. Which one is named selects which settings table below is read.
[auth] scopes Required scopes Authentication and approval list of str empty Which scopes a token must carry, where any are required.
[auth] registration_hosts Client registration hosts Authentication and approval list of str empty Where a self-registering client's return addresses may be. Empty means no hosted client can register itself.
[auth] approver_may_draft Self-approval Authentication and approval bool false Whether whoever raised a draft may also approve it. Off by default: the gate exists so a second pair of eyes commits the record.
[auth] review_page_client Review page client id Authentication and approval str revetly-review The public client id the review page signs people in with. A public client (PKCE, no secret) at the declared issuer.
[backend] pbi Approved item store Approved item store str repo the write adapters this instance registers — azure-devops (Azure DevOps board), github (GitHub board), repo (This project's folder), todoist (Todoist board) Where an approved draft is written; naming nothing writes it nowhere. A folder in this project, or straight back onto the board this project reads. Naming nothing at all is a declaration rather than an omission: approved drafts are then written to no store.
[backend] id_family Backlog id family Approved item store str empty One id family for the whole backlog, so every type numbers together. Empty leaves the store whatever scheme it already had. Declared alongside a prefix per type is refused.
[backend] id_prefixes Id prefix per type Approved item store table empty What new ids start with, one prefix per item type. One prefix per type, so adding a type does not renumber the backlog onto one family.
[backend] tracker_note Tracker delivery note Approved item store bool true Whether shipping posts a delivery note on the tracker item. A ticket in somebody's board has nowhere to put the delivery record, so after a ship the engine renders the record into a comment and the write adapter posts it, with a pointer line in the item body. Off writes nothing to the item and behaves exactly as it did before, and the local record is written either way.
[backfill] ceiling_episodes Backfill episode ceiling Extraction and spend int (episodes) 0 How many episodes one backfill may send before it stops. It stops at a window boundary rather than mid-window. Zero is undeclared, and a backfill refuses to run under it.
[backfill] ceiling_spend Backfill spend ceiling Extraction and spend float (USD) 0.0 The same ceiling in USD; zero is undeclared, not unlimited.
[backlog] types Item types Item types and parents list of str epic, feature, story The item types this project has; epic, feature and story by default. What a store can actually create is the store's answer, not this one's.
[backlog] default Default item type Item types and parents str story Which type a draft naming none becomes, from the declared types.
[backlog] parents Parent type map Item types and parents table {'epic': (), 'feature': ('epic',), 'story': ('feature', 'epic')} Which item types may sit under which. The whole hierarchy comes from here, and a declared map replaces the default outright.
[backlog] parent_required Types requiring a parent Item types and parents list of str feature Which item types must name a parent rather than may.
[capture] ingest Extraction timing Watch and retries str background background (Run in background), sync (Wait for extraction) Whether a save returns at once or waits for extraction to land. Run in background is right unless the answer is needed in the same breath; wait for extraction holds the call open until extraction has landed.
[capture] recover_attempts Read pass retries Watch and retries int (attempts) 2 How many attempts a failed read pass makes at itself; two by default.
[capture] recover_delay First retry delay Watch and retries float (seconds) 60.0 [capture] recover_attempts is set Seconds before the first retry; the wait doubles after each one.
[capture] episode_window Context episodes per call Extraction and spend int (episodes) 0 How many preceding episodes ride along on every extraction call. Zero sends none. Documents carrying explicit ids need none; chat transcripts need a few, because who they is only said further up.
[config] port Configuration page port Ports, updates and containers int 9714 The port this page listens on; nothing listens until the page is started. Unlike the update service, declaring the section switches nothing on.
[config] expose Configuration page reach Ports, updates and containers str loopback loopback (This machine only), all (Every attached network) Whether this page answers beyond this machine; a claim, not a bind. The page binds every interface inside its container whatever this line says, so it is a label on the declaration rather than something it enforces: a compose ports line is what carries it out, and the engine cannot confirm what is published.
[connectors] enabled Enabled sources Sources list of str pm, docs, conversations, deliveries the connectors this instance registers — azure-devops (Azure DevOps), commits (Git commits), conversations (Conversation notes), deliveries (Delivery records), docs (Documents), github (GitHub), pm (Backlog items), todoist (Todoist) Which sources this project reads; an empty list reads nothing, deliberately. An empty list is a declaration and not an oversight — nothing is read, and Revetly guesses nothing.
[docs] roots Document folders Sources list of str docs Folders inside this project whose prose is read into the graph. Every file under one of them is a spending decision: the prose in it goes through extraction on the next read pass.
[egress] withhold_artifacts Withheld artifacts Sensitivity and withholding list of str empty Artifact ids whose bodies are withheld from every provider call. They are still read, still committed and still in the ledger — it is the body that stays here, not the record of it.
[egress] withhold_sources Withheld sources Sensitivity and withholding list of str empty Whole sources whose bodies are withheld from every provider call.
[egress] withhold_kinds Withheld artifact kinds Sensitivity and withholding list of str empty Artifact kinds withheld from every provider call, optionally narrowed to one source. An entry is a kind, or a source and a kind together where only the one source's are meant.
[egress] withhold_above Highest classification sent Sensitivity and withholding str empty the classifications this engine knows — confidential (Confidential), internal (Internal), public (Public), restricted (Restricted) Anything a source classifies above this level is withheld. Public is publishable as it stands; internal is inside the organisation's own walls; confidential is somebody else's private data; restricted is credentials and regulated data. Naming the most restricted classification withholds nothing, and empty withholds nothing at all. It depends on the source declaring a classification in the first place.
[egress] library_telemetry Graph library telemetry Sensitivity and withholding bool false Whether the graph library may send usage data to its vendor. graphiti-core posts an anonymous id, its version and the clients it was handed to its vendor's endpoint on every graph construction. Off by default: extraction is meant to be the only sink outside the perimeter, and a process-level variable outranks this line.
[embedding] provider Embedding provider LLM and embedding str empty the providers this engine registers — anthropic (Anthropic), cloudflare (Cloudflare), none (No provider (lite instance)), openai (OpenAI), openai-compatible (Any OpenAI-compatible endpoint), openrouter (OpenRouter) Which provider makes text searchable; empty follows the LLM provider. Empty follows the LLM provider, where that provider can also embed; required where it cannot — Anthropic serves no embeddings. A different vendor from the one that runs extraction is normal here.
[embedding] endpoint Embedding endpoint LLM and embedding str empty [embedding] provider differs from [llm] provider A different base URL for embedding calls; empty follows the LLM endpoint. Empty follows the LLM endpoint on the same provider, or the provider's own address.
[embedding] key_env Embedding key variable LLM and embedding str empty [embedding] provider differs from [llm] provider Names the environment variable holding the embedding API key. Empty follows the LLM key variable on the same provider, or the provider's own.
[embedding] account Embedding account id LLM and embedding str empty [embedding] provider is cloudflare The account a per-account embedding endpoint belongs to. Empty follows the LLM account id on the same provider.
[embedding] model Embedding model LLM and embedding str empty The model that turns text into vectors. Changing it on a corpus that exists changes the vector space: everything has to be read again and paid for again, which is a corpus revision and never a silent switch. Empty is the provider's pin (text-embedding-3-small on OpenAI); an OpenAI-compatible provider must declare one.
[embedding] module Embedding provider module LLM and embedding str empty A file in this instance holding the embedding provider. As the LLM provider module does for the extraction half.
[embedding] headers Embedding header variables LLM and embedding table empty [embedding] provider differs from [llm] provider Extra headers on every embedding call, each naming a variable. Empty follows the LLM header variables on the same provider — one gateway, one set of headers — and the same header names are refused.
[embedding] params Embedding request fields LLM and embedding table empty [embedding] provider differs from [llm] provider Extra JSON fields merged into every embedding request. Empty follows the LLM request fields on the same provider.
[engine] source Engine source folder Ports, updates and containers str empty Where the engine code this build is compared against lives. Relative to the project root. Read by the doctor chain and by nothing else.
[extraction] statements Statement extraction Extraction and spend bool true Whether decisions, assumptions and constraints are extracted too. Better answers, bigger bill. Off, Revetly still reads everything — it just will not say why something was decided. A smaller corpus, not a broken one, and an indexed artifact makes no extraction call either way.
[extraction] tiers Extraction tier per kind Extraction and spend table {'commit': 'index', 'delivery-record': 'index', 'delivery-note': 'index', 'revision': 'index'} entities (Extract with the LLM), index (Index without extraction), statements (Statements only) Which artifact kinds go through the LLM and which are only indexed. Per kind, merged over the engine's own defaults. Extract with the LLM sends the kind through the model; index without extraction keeps the whole body searchable and every declared and explicit link, and makes no extraction call at all. Statements only indexes the kind the same way and then makes one LLM call per artifact for its decisions, assumptions and constraints, where the kind carries them. Commits, delivery records and revisions are indexed unless a line here says otherwise.
[extraction] concepts Concept naming Extraction and spend str declared declared (Product concepts only), open (Anything it finds) Whether the extractor names product concepts only, or anything it finds. Product concepts only tells the extractor to name product concepts and nothing else — never code identifiers, paths, shas, versions or git identities — and withdraws the ones that slip through afterwards. Anything it finds lets it name whatever it likes, which is what every pass did before this line existed.
[extraction] aliases Concept aliases Extraction and spend table empty Other spellings of one concept, folded onto a single node. Canonical name to its other spellings, so five ways of writing the product's name are one entity: the extractor is told, and the pass folds what the corpus already holds onto one node at no provider cost.
[extraction] retractions Retracted edges and concepts Extraction and spend table empty Which inferred links and concepts this instance takes back. Two lists, each row carrying its reason: edges by source name, relation and target name, concepts by name. A pass at the tail of every read invalidates the edges and takes the concepts off the shelf, at no provider cost, so a wrong inference stays taken back through every re-read. Nothing is erased and nothing a source stated is touched — removing a row here does not restore what it retracted.
[git] fetch Git fetch Git and records bool true Whether a repository is fetched before it is read. Off pins reading to whatever the checkout holds, which is what a deliberately offline machine wants.
[git] push Git push Git and records bool true [git] records_clone is set Whether the records Revetly writes are pushed to their remote.
[git] records_clone Records clone Git and records str empty A checkout Revetly owns and is the only writer of. Empty reads and writes the records store where it already sits, which means sharing a working tree with whoever else is in it. Declared for the instance, it is relative to the instance and every project writes into it, each in its own folder.
[git] records_remote Records remote Git and records str origin [git] records_clone is set The remote that clone is fetched from and pushed to; origin by default.
[git] records_ref Records branch Git and records str empty [git] records_clone is set Which branch of that clone; empty keeps the branch it is on.
[git] credentials Git credentials Git and records table empty Named ways into private repositories, each naming the variable holding its token. Each entry names the environment variable its token is read from. A token value here is refused: the configuration file is committed, so the name of the variable goes in the file and the token itself never does. Pushing needs an entry declared writable.
[instance] default_project Default project Project defaults str empty 2 or more projects are declared Which project answers when a question names none.
[llm] provider LLM provider LLM and embedding str openai the providers this engine registers — anthropic (Anthropic), cloudflare (Cloudflare), none (No provider (lite instance)), openai (OpenAI), openai-compatible (Any OpenAI-compatible endpoint), openrouter (OpenRouter) Which provider runs extraction on the sources this instance reads. OpenAI, an OpenAI-compatible endpoint (Azure, vLLM, Ollama, Gemini's compatible endpoint), Anthropic, OpenRouter, Cloudflare, or one installed as a revetly.providers entry point. Instance-wide — a project table re-declaring it is refused.
[llm] endpoint LLM endpoint LLM and embedding str empty A different base URL to call; empty uses the provider's own. https://api.openai.com/v1, https://api.anthropic.com; an OpenAI-compatible provider has none of its own and must declare one. The SDK's own base-URL variable found in the environment with a different value stops the engine before a client is built.
[llm] region LLM region label LLM and embedding str empty Where that endpoint is, as the operator states it; never validated. eu, us-east — printed by revetly doctor and context_health and never validated: the engine cannot know, so this is the operator's statement rather than a fact it checked.
[llm] key_env LLM key variable LLM and embedding str empty Names the environment variable holding the API key, never the key itself. Empty is the provider's own: OPENAI_API_KEY, ANTHROPIC_API_KEY.
[llm] workspace LLM workspace LLM and embedding str empty [llm] provider is anthropic The provider workspace this key belongs to; an Anthropic console key needs one. Where the provider asks for one: Anthropic sends it as the anthropic-workspace-id header, which an identity-linked console key needs and a workspace-bound key does not — the provider refuses the former without it, and revetly doctor names this line as the fix. Providers with no use for it ignore it.
[llm] account LLM account id LLM and embedding str empty [llm] provider is cloudflare The account a per-account endpoint belongs to; Cloudflare needs one. Cloudflare addresses one account per URL and its default endpoint carries {account} until this fills it (wrangler whoami prints it). An endpoint still carrying the placeholder at load is refused rather than sent.
[llm] model LLM model LLM and embedding str empty The model that does extraction, and where quality shows up. Empty is the engine's pin for the provider in force (gpt-4.1-mini, claude-sonnet-5); an OpenAI-compatible provider has none and must declare one. An LLM model set in the environment outranks this line.
[llm] small_model LLM high-volume model LLM and embedding str empty The cheaper model for repetitive extraction work; most calls, most of the bill. Matching up names, filling in dates, deciding two records name one artifact — the dull work, and by far the most calls, so going cheaper here moves the bill more than going cheaper on the LLM model. Empty is the provider's pin (gpt-4.1-nano on OpenAI); a provider whose client sends every call to one model ignores it.
[llm] module LLM provider module LLM and embedding str empty A file in this instance holding a provider Revetly does not ship. A Python file whose provider is a revetly Provider — the third door, beside the built-ins and an installed entry point. It matters only where the provider name is unregistered.
[llm] headers LLM header variables LLM and embedding table empty Extra headers on every LLM call, each naming the variable holding its value. Declared as header = "VARIABLE" — the NAME of the environment variable holding the value, never the value. For a gateway that wants a second credential beside the key (cf-aig-authorization) or a name for itself (cf-aig-gateway-id, HTTP-Referer). Authorization and the key headers are refused: the engine sets those from the key variable.
[llm] params LLM request fields LLM and embedding table empty Extra JSON fields merged into every LLM request body. OpenRouter's provider = { data_collection = "deny", zdr = true }, say. The engine states what it sent and cannot verify the endpoint honoured it. Fields the client writes itself (model, messages, input, response_format, stream) are refused.
[pricing] models Model rates Extraction and spend table empty What this account pays per million tokens, by model name. Matched by prefix, longest first. Empty uses the engine's own dated figures, one sheet per provider, and says which sheet it read.
[project] group_id Project namespace Namespace and identity str required — no fallback The namespace every artifact this project saves carries. Typed by a human and never derived from a directory name. Renaming it moves nothing already saved — Revetly will not do that for you, and two projects that would carry the same namespace are refused.
[project] source_system System id Namespace and identity str repo Names this instance as a system, so ids from two systems cannot collide. Artifacts saved here keep plain ids; everything read from anywhere else is marked with where it came from, so two systems using the same id cannot be confused for one another.
[project] source_instance Instance id Namespace and identity str empty Which instance of that system this is, where the system id alone is short. Empty takes the project namespace.
[records] conversation Records conversation folder Records folders str empty The conversation notes folder when Revetly owns a separate records store. For an instance whose backlog lives in a store it only reads. Empty falls through to the project folder set above.
[records] delivery Records delivery folder Records folders str empty The delivery records folder when Revetly owns a separate records store. For an instance whose backlog lives in a store it only reads. Empty falls through to the project folder set above.
[routing] pbi Backlog folder Records folders str sources/backlog The folder new backlog items are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder.
[routing] conversation Conversation notes folder Records folders str sources/conversations The folder conversation notes are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder.
[routing] delivery Delivery records folder Records folders str sources/deliveries The folder delivery records are written to, relative to the project folder. Where an approved draft, a captured conversation or a delivery record is filed, relative to the project folder.
[search] fuzzy Fuzzy term matching Search and ranking bool true Whether a long search term also matches indexed words one or two letters away. On, a term of five to eight characters also matches indexed words one letter away, and a longer one words two letters away. The comparison runs against the index's stemmed words (mooring is stored as moor), so a typo in a word the stemmer shortens is still not found: ingestino does not reach ingestion. What it changes is ranking: a term the stemmer leaves whole counts twice and also matches its near-spellings (store matches story). Off, every term matches only as the index spells it. Measured, off ranked real agent queries slightly better and model-written queries slightly worse, so the default stays on. A term of four characters or fewer, a bare number and an id this engine recognises are matched exactly either way, and a binding topic scope never uses it.
[search] recency_half_life_days Recency half-life Search and ranking int (days) 180 How many days until a result counts half as much for being recent. Ranking only, never filtering: nothing is ever hidden for being old. A result whose source was last changed one half-life ago is worth half a fresh one at the same relevance, two half-lives ago a quarter, and so on. Raise it where the corpus is a slow-moving record; lower it where last week is what matters. Measured on the source's own clock, not on when Revetly read it. Zero is refused — it is a division nobody meant rather than a way of saying no decay.
[search] provenance_weights Provenance weights Search and ranking table {'source-of-record': 1.0, 'human-authored': 0.9, 'mirror': 0.6, 'ai-derived': 0.5} What a claim is worth in the ranking, by where it came from. One weight per provenance a connector may declare, each above zero and at most one, merged over the engine's own figures — saying what a mirror is worth says nothing about what a decision note is. This is the whole reason provenance ranks at all: a mirror and the record it copies say the same words, and one of them is the authority. A weight for a provenance no connector may declare is refused naming it.
[sensitivity] patterns Sensitivity patterns Sensitivity and withholding table empty Extra patterns the capture-time scan looks for, as name and expression.
[sensitivity] disabled Disabled patterns Sensitivity and withholding list of str empty Which of the engine's own sensitivity patterns this instance does not run.
[served] public_url Public address Authentication and approval str empty The address people reach this instance at, where that differs from the bind. Consulted before the resource identifier.
[served] allowed_origins Allowed browser origins Authentication and approval list of str empty Which browser origins may call this instance's served endpoint. Undeclared emits no CORS header at all.
[update] source Update source Ports, updates and containers str empty Where this instance's releases are published. Empty uses the address baked into the image.
[updater] port Updater port Ports, updates and containers int 9713 [updater] is declared The port the update service listens on. Declaring this section is what turns that endpoint on, because what it holds is a Docker socket.
[updater] expose Updater reach Ports, updates and containers str loopback loopback (This machine only), all (Every attached network) [updater] is declared Whether the update service answers beyond this machine. This machine only binds the loopback address; every attached network binds them all.
[updater] swap_timeout Restart timeout Ports, updates and containers float (seconds) 120.0 [updater] is declared Seconds a container swap gets before it reads as stalled.
[watch] enabled Watch loop Watch and retries bool false Whether Revetly runs unattended read passes on this instance. Off, the served engine starts no loop of its own, and revetly watch still runs a pass when somebody types it. On, a pass runs on the watch interval unattended. Always off on a fresh instance.
[watch] interval Watch interval Watch and retries float (seconds) 300.0 Seconds between unattended polls for new work. Counting from the end of the last pass. Zero is watch reading declared off: the loop runs at no time, and revetly watch refuses. The switch above turns the served engine's own loop on and off.
[watch] full_interval Full read interval Watch and retries float (seconds) 21600.0 Seconds between full read passes, whatever the poll said.
[watch] backoff_max Maximum backoff Watch and retries float (seconds) 3600.0 The longest a failing source waits before it is tried again, in seconds.

A project's own keys

Declared under [projects.<key>], beside the [project] keys above.

Option Name Section Type Default Accepts Shown when What it is
root Project folder Namespace and identity str empty The folder this project's sources are in, relative to the instance root. It has to be a folder the running engine can actually open — a boxed-off engine was never given most of the machine.
imports Imported projects Imports and exports list of str empty Which projects or shared namespaces this project may read. Closed by default: content crosses only where an exporter and an importer agree, so ticking one direction writes both halves of it.
exports Exported projects Imports and exports list of str empty Which projects may read this project; both sides must agree.
isolated Isolation Imports and exports bool false Nothing leaves this project and nothing reaches it. Another project naming it is a load error rather than a line quietly ignored.

Tables this engine carries and does not read

The keys inside these belong to a component — a connector, a write adapter, a token verifier — or name a project rather than a setting. The engine declares nothing about them and rules on nothing inside them, which is why they are not in the generated table above.

Which half of the surface is which. Everything in the generated table is the engine's: it declares those options, checks them, and refuses a value outside the set it accepts. Everything in the table below is a component's, and the engine's position on it has not changed — it carries the table to whoever owns it and rules on no key inside it. What changed is who else may speak: a connector, a write adapter or a token verifier can now declare what it reads out of its own table, in the same configspec.Option vocabulary the engine's own options use, and where one has:

Declaring is optional and stays optional, and so are the three the page groups by: a component that states no tier, subject or label is rendered under Advanced, filed against the source it reads, and named by its own key. Only the engine's own options must state all three, and a test fails on one that does not. A component that declares nothing at all has its table carried through verbatim, byte-identical on save, exactly as before — and is reported unchecked, never as passed. revetly check --contract publishes what each connector this engine ships declares, table by table; what an author writes is in writing-a-connector.md §4b and writing-a-write-adapter.md §2.

Two sub-tables under those sections are the engine's vocabulary rather than a component's, and are read by the loader rather than carried to anybody: [auth.grants] and [auth.tokens] (below), and [backend.id_prefixes]. A component of one of those names would be the collision that reservation exists to make visible.

Input Source Required Notes
[backend.<name>] revetly.toml no One write adapter's own settings, carried verbatim to that adapter and read nowhere else — the write-side twin of [connectors.<name>]. module and factory are read by the loader; everything else is the adapter's. A credential value here is refused at load (the file is committed): name the variable that holds it (token_env = "ACME_TOKEN"). A key the adapter never reads is reported by revetly doctor as declared-but-never-read.
[auth.<name>] revetly.toml no One token verifier's own settings, carried verbatim and read nowhere else — the same shape as [connectors.<name>] and [backend.<name>]. A credential value here is refused at load (the file is committed): name the variable that holds it (client_secret_env = "REVETLY_AUTH_CLIENT_SECRET"). The verifier this engine ships with is introspection (RFC 7662): endpoint is required, client_id and client_secret_env are its credential, timeout (default 10s) bounds the call every request waits on. It asks the issuer itself on every request rather than checking a signature locally and rather than caching — a cache here is a window in which a token withdrawn five minutes ago still reads the corpus. A token whose aud names another resource is refused (the confused deputy RFC 8707 exists to stop); an issuer that returns no audience at all is said once and its tokens stand, since refusing them would refuse every deployment behind an issuer that does not implement it. An introspection endpoint that cannot be reached answers nobody — "cannot say who is asking" and "nobody" are the same answer at a door. The second verifier is jwt: it fetches the issuer's published keys — at jwks_uri if declared, otherwise from whichever of /.well-known/openid-configuration or /.well-known/oauth-authorization-server the issuer answers — and checks signature, iss, exp and aud locally. algorithms narrows what it accepts and may name asymmetric algorithms only, since an HMAC token verified against a key from the issuer's public key set is the classic forgery; key_ttl (default 600s) is how long a fetched set stands before the next call refreshes it, leeway (default 60s) is the clock skew tolerated on exp, timeout (default 10s) bounds a fetch. A token whose aud is not this resource is refused, and so is one carrying no aud at all — stricter than introspection, because here the audience is in the token and one without it was not minted for this resource. No refusal echoes the token. Keys are cached: a rotation is picked up on the next unknown kid (throttled, so a stranger guessing key ids cannot make this engine hammer its issuer), an issuer that goes down does not invalidate what it already published, and one that was never reachable verifies nobody. The third is static, which reads no settings table of its own: a token's settings are the token, declared under [auth.tokens.<name>]. It is selected by declaring one rather than by being named, checked before the issuer's verifier because it costs a hash rather than a signature, and it is the only one that authenticates a machine.
[auth.grants.<claim value>] revetly.toml no What a caller carrying that claim value may reach: role, the lens, and projects, the namespaces. Exactly those two keys; anything else is refused by name. The permitted set for a call is the intersection of this and the project's own imports/exports — a grant can only narrow, and config keeps its veto. A grant naming a project this instance does not declare, or a declared project no grant reaches, is a revetly doctor finding rather than a load error: neither authenticates anybody, and stopping ingest over one is the wrong blast radius.
[auth.tokens.<name>] revetly.toml no A bearer token this instance declares itself, by its digest: hash = "sha256:<hex>", a compulsory expires (a bare date is the whole of that day, UTC), and grant, naming an [auth.grants] entry. The keys that would hold the token itself — token, value, secret — are refused by name, because this file is committed. No browser, no issuer, and it works when the authorization server is down, which is the hour a CI job most needs the corpus. Whatever presents one is attributed as a machine rather than a person.
[projects.<key>] revetly.toml no One project of this instance. The table name is the key, written by hand — never derived from a directory name, a path, or the namespace string. group_id is its own graph namespace and is never inherited; root (default .) is where its sources, ledger, health record and lock live. imports/exports are the two directions of what may cross, both closed by default and both required for anything to; isolated = true closes both and makes any other table naming it a config load error. A project may re-state any section above under [projects.<key>.<section>] and inherits the ones it does not. A section is read key by key, but a settings table under one ([projects.<key>.connectors.todoist]) is a single key and replaces the instance's whole — merging would leave no way to unset an inherited key. The one exception is the connector's own connection keys: on an instance declaring two or more projects, a project's table that states none of them takes exactly those keys from the instance's table — Todoist's token_env, base_url, timeout; Azure DevOps's token_env, organization, base_url, api_version, timeout; GitHub's token_env, base_url, api_version, timeout — and a table that states any of them states its own connection whole and inherits nothing. A connection is established once and reused; scope is never inherited, which is what replace-not-merge was always protecting. What that replacement dropped is named by revetly doctor and by revetly ingest --dry-run rather than left to be discovered in the ids, and revetly doctor --project NAME / revetly check name, per source, whether it runs on a connection of its own or the instance's, and revetly doctor --project NAME / --all walk the per-project chain for one or for every project rather than the default alone. [projects.<key>.routes] maps one of its connectors onto the shared namespace that source's artifacts are ingested into. Two projects may share neither a group_id nor a root. An instance declaring one project is unchanged in every observable way.
[shared.<key>] revetly.toml no A namespace beside the projects that several of them resolve into — a third namespace, never a merge of two. group_id is its own, distinct from every project's; exports names the projects that may read it, closed by default like any other reach. It carries its own manifest and its own budget line under .revetly/shared/<key>/, so a pass over a project still bills only that project's artifacts. Moving an artifact into or out of it is a re-extraction and is priced and confirmed by revetly rehome rather than happening because a line was edited.

GIT_AUTHOR_* and GIT_COMMITTER_* are read by git rather than by this engine, and override the image's baked revetly-docker autocommit identity per instance. They are named here because an operator looking for them should find them, not because anything in the engine reads them.

The sections, with an example each

Every section above as something to copy. Nothing here is required except group_id and the four credentials: an instance that declares none of the rest gets the defaults the table states, which are the ones this engine was built around.

Credentials and provider, in .env beside revetly.toml. Never in the committed file — a value in a git history is not recoverable:

OPENAI_API_KEY=sk-...             # the variable `[llm] key_env` names; ANTHROPIC_API_KEY on anthropic
NEO4J_URI=bolt://neo4j:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=...                # minted per instance by `revetly init`
# Cost levers, all optional. The model is the big one.
REVETLY_LLM_MODEL=gpt-4.1-mini
REVETLY_LLM_CONCURRENCY=4
REVETLY_LLM_MAX_ATTEMPTS=5

A key saved into this file — by hand, or from the configuration page — is read by the next command inside an engine container that is already running. No docker compose up, no recreate: the compose file passes each provider key through as ${OPENAI_API_KEY:-}, and an empty passthrough is not a value, so the file outranks it exactly as it outranks an absent name. What still wins is a value that was genuinely in the environment when the process started — a secret a deployment injected, or a container started from an older .env. revetly doctor's secrets line says, for each secret-bearing name, which value is in force and where it came from, printing neither.

Declaring no provider at all. revetly init --lite writes [llm] provider = "none" and [embedding] provider = "none", both in one declaration in the revetly.toml it scaffolds. A lite instance calls no vendor, reads no key and is billed for nothing: every kind is indexed, links are the deterministic ones a source stated and search is keyword-only, with no semantic search, no concepts, no inferred links and no prose-only statements. A kept revetly.toml is never rewritten, so this reaches a file init writes and never one it keeps; naming a real provider on both sinks is what makes such an instance a full one. revetly manual show providers has what a lite instance answers in full.

Security note. Two answers revetly init decides for you, because the corpus is every artifact body this instance holds, in plaintext, behind them:

[project] — the namespace this instance's artifacts live in. The one key with no default:

[project]
group_id = "acme-platform"       # this instance's graph namespace
source_system = "repo"           # its half of the id namespace
source_instance = ""             # defaults to group_id

[routing] — where this instance's own sources are. Three kinds, all with defaults:

[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"

[records] — where the records Revetly originates land. Its own seam, deliberately not [backend] pbi: connecting a tracker replaces where the ask lives and nothing else, because no tracker has a field for "criterion 3 departed, decided by the builder, because X":

[records]
conversation = "decisions"
delivery = "shipped"

[docs] — the doc roots read into CONTEXT. Every markdown file under one is sent on the next ingest, so this is a spending decision:

[docs]
roots = ["docs", "architecture"]

[connectors] — which sources this instance reads, and their own settings. A connector is enabled by being named here and nowhere else:

[connectors]
enabled = ["pm", "docs", "conversations", "deliveries", "todoist"]

[connectors.todoist]
account = "acme"                 # the namespace half of every id it mints
token_env = "TODOIST_API_TOKEN"  # the VARIABLE, never the token
project = "Roadmap"

A source with no current state is floored per unit. A git repository has no small snapshot — its history is its snapshot — so the commits connector reads each repository forward from the moment that repository was connected to this instance, recorded durably in .revetly/connections.json and never re-derived from the current pass. Declaring nothing is now the ordinary case, and it costs what the repository has done since somebody wired it up:

[connectors.commits]
repos = ["../product-api", "../product-web"]

[connectors.commits.since]       # OPTIONAL, and per repository
"../product-api" = "2026-01-01"  # keyed exactly as `repos` writes it

since as a bare string is the older shape and still honoured — one floor standing in for every repository under it — and revetly doctor says so, per repository, because a date chosen for a repository created last week decides nothing sensible about a five-year one that joined later. Moving a floor earlier does not make the next unattended pass read further back: the span is reported as history that is now available and unread, and reading it takes revetly backfill, which prices per window before it sends anything.

[backend] — where approved drafts land. A missing key is the repo store; pbi = "" writes nowhere and says so at the gate:

[backend]
pbi = "todoist"

[backend.acme]                   # one adapter's own table, read nowhere else
module = "backends/acme.py"
project_key = "PROJ"

Two id levers, two different intentions — state one of them. Both matter only to a store that mints ids locally; a tracker assigns its own on create and reads neither.

[backend]
id_family = "PROJ"               # ONE number space for the whole backlog:
                                 # PROJ-001, PROJ-002, whatever the type.
                                 # For a backlog adopted with its ids already
                                 # decided — the id states no type, and it does
                                 # not need to: the type is the `type` field.
[backend.id_prefixes]            # ONE number space PER PBI type: the repo store
bug = "BUG"                      # has EPIC/FEAT/STORY built in, and a fourth
                                 # type declared in [backlog] types needs a
                                 # prefix of its own. A type not named here
                                 # keeps its default, so declaring nothing
                                 # mints exactly as it did before.

Declaring both is a named stop rather than a precedence rule nobody can read off the file. A prefix is never derived from the type's name: bug does not silently become BUG, because an id is a name people live with for years and two types opening the same way have no non-arbitrary answer. A declared prefix is shape-checked at load like id_family, and two types landing on one prefix — declared or built in — is refused, because that is id_family written the hard way. Existing ids never move: adding a type renumbers nothing.

[backlog] — this project's PBI type vocabulary. Declaring nothing is the engine's own three. The hierarchy is [backlog.parents]; the order of types means nothing, and default says which type a draft naming none becomes:

[backlog]
types = ["epic", "feature", "story", "bug"]   # `levels` was the old name (retired)
default = "story"                             # else the last entry above
parent_required = ["feature"]

[backlog.parents]
epic = []
feature = ["epic"]
story = ["feature", "epic"]
bug = ["feature", "epic"]                     # a sibling of story, not a rung

A flat store is two lines — types = ["story"] with story = ["story"], which is what a Todoist subtask is.

[capture] — what a write tool does before it returns:

[capture]
ingest = "background"            # or "sync" — whether a write blocks on extraction
recover_attempts = 2             # how many times a failed pass retries itself
recover_delay = 60               # seconds before the first retry, doubling after
episode_window = 0               # preceding episodes inlined into every call

[watch] — unattended passes. enabled = true has the served engine start the loop; revetly watch also runs one by hand. enabled defaults to false, so a fresh instance runs nothing unattended until somebody turns it on:

[watch]
enabled = true                   # let the served engine start the loop
interval = 300                   # seconds between ticks; 0 declares watch off
full_interval = 21600            # a full read regardless, every 6 hours
backoff_max = 3600               # a failing source's ceiling, per source

enabled gates only the loop the served engine starts. A typed revetly watch runs on interval, full_interval and backoff_max whatever it says, so the page shows those three rows whether the switch is on or off. Neither loop runs a project's first pass: until a pass is on record, a tick reads nothing and names the command that prices one.

[extraction] — whether the why is extracted at all, which kinds a model reads, and what it may name:

[extraction]
statements = false               # decline Decision/Assumption/Constraint
concepts = "declared"            # the default; "open" lets the extractor name anything

[extraction.tiers]               # merged over the engine's defaults, per kind
commit = "index"                 # the default: written by the engine, read by no model
delivery-record = "index"        # the default
revision = "index"               # the default
comment = "index"                # an instance's own choice for a kind it reads

[extraction.aliases]             # canonical name = its other spellings
Acme = ["acme", "acme_platform", "Acme Platform"]

Tiers. An entities-tier artifact is extracted as every artifact was before: entities, edges, a summary, and — for prose — the three statement shelves. An index-tier artifact is written by the engine: its whole body on an episode of its own, searchable by keyword and served by search_context as a passage; its node anchored and recorded by the deterministic passes; every declared relation (implements, delivers), every explicit tracker-id mention, every citation and supersession exactly as before; and no model call at all. It loses only what a model would have inferred from it. The defaults are the three kinds whose every link is already deterministic — commits, delivery records and revisions — which on the corpus this engine was measured against were 63% of provider calls. revetly ingest --dry-run prices the two tiers apart. Moving a kind from index to entities re-sends exactly the artifacts no model has read; the other direction re-sends nothing and retires nothing (revetly forget is the gesture that takes an extraction back). revetly manual show extraction-policy has the whole of it.

Concepts. Under declared every extracted episode carries instructions rendered from this project — product concepts only; never code identifiers, paths, shas, versions, git identities, or another artifact's id in passing; the declared aliases as one entity — and two deterministic passes at the tail of every ingest make the same rule true of the corpus that already exists: the alias fold moves every edge of a matching concept node onto the canonical one and takes the duplicate off the shelf, and the noise pass withdraws names shaped like a sha, a path, a version or an identifier, reason recorded, node and edges kept. Neither calls a provider. revetly audit-links --concepts is the census, before and after. Under open no instructions are passed and the noise pass does not run; the alias fold runs whenever aliases are declared.

The admission test. Five types ship — two shelves an artifact stands on, and three a statement does — and the count is load-bearing: every one costs on every artifact it is read for, forever. A sixth is argued against these four questions or it does not land —

  1. No source states it. A field a tracker already has is read deterministically and costs nothing; extracting it a second time buys a worse copy of an answer the engine already holds.
  2. It is not computable from what is stored. Roadmap, drift and cycle time are arithmetic over the graph and belong in reads, not in shelves.
  3. It answers a question nothing else answers.
  4. That question is asked often enough to repay a permanent per-episode slot.

epic, sprint, release, priority and assignee fail the first. OKRs, opportunities and experiments fail it too — they are stated by other tools, and the answer is a connector rather than a type. Decision, Assumption and Constraint are what is left: the why behind a product, which exists only as prose and which nobody's tracker has a column for.

And a fifth question the first build had to pay to learn: where does it land? Offering the three to the entity extractor put propositions into the same namespace concepts are resolved in, and a decision paragraph absorbed the ProductBacklogItem node along with seven facts that were not about it. Entity resolution decides identity by name similarity, and a statement is prose that names concepts, so it will keep colliding with them. A type that names no entity does not belong on that path — it is read by a pass of its own and written by the engine, which is what statements.py is.

[search] — fuzzy matching, recency and what a copy is worth beside its record. It is its own section rather than three more keys under [extraction] because nothing here extracts or spends — it only reorders what a pass has already read:

[search]
fuzzy = true                     # the default
recency_half_life_days = 180     # the default

[search.provenance_weights]      # merged over the engine's own defaults
mirror = 0.5

fuzzy. On by default: a typed word, inside the keyword leg's own bounds (one edit at five to eight characters, two at nine and over, none shorter), also matches indexed words that close. The edit is measured against the index's stems rather than the words, so it is not typo tolerance: a typo in a word the stemmer shortens (ingestino, landng) still finds nothing. What it does is rank — a word the stemmer leaves whole matches twice, and its near-spellings with it. Off ranks real agent queries slightly better and model-written ones slightly worse, which is why the default stays on. An exact-id clause, a quoted phrase and a binding topic scope are never fuzzy either way.

recency_half_life_days. How many days it takes a passages or artifacts result's ranking weight to halve; 180 by default. It has to be a positive number of days, 1 to 36500; a zero — the division nobody meant — is refused naming the key. binding is never aged by it: what already binds does not decay by being read again.

provenance_weights. How much a hit is worth by where it came from, one weight per provenance a connector may declare — source-of-record, mirror, human-authored, ai-derived — each greater than zero and at most one. The engine's own defaults are source-of-record = 1.0, human-authored = 0.9, mirror = 0.6 and ai-derived = 0.5 — a source-of-record hit above a mirror of the same words; a declared table merges over them the way [extraction] tiers does, so naming one provenance says nothing about the rest. A key outside those four, or a weight outside the range, is refused naming what was wrong.

revetly manual show mcp-client-surface has what the four signals do to a search_context answer; revetly manual show extraction-policy has what the keyword leg itself does.

[sensitivity] — what the pre-capture scan looks for:

[sensitivity]
disabled = ["aws-access-key"]    # engine patterns this instance does not run

[sensitivity.patterns]
acme-internal-id = "ACME-[0-9]{6}"

[egress] — what never reaches the LLM provider. Read, committed, in the ledger, and never sent:

[egress]
withhold_artifacts = ["STORY-000"]
withhold_sources = ["todoist"]
withhold_kinds = ["comment", "todoist:comment"]
withhold_above = "internal"
library_telemetry = false

withhold_above names a classification tier rather than a list, and it is the only one of the four that reaches an artifact nobody has met: the other three name ids, sources and kinds an operator has already seen, while a connector's declared sensitivity governs the ticket that arrived this morning. Anything a source declares strictly above the named tier never crosses — "internal" withholds confidential and restricted and sends the rest. The vocabulary is public, internal, confidential, restricted, least to most restricted; a value the engine cannot place fails fast, because an unplaceable tier ranks below public and a typo would silently send exactly what this line was written to keep back.

library_telemetry is the odd one out and off by default, where the other four withhold nothing until you name something. It is not about your artifacts at all: graphiti-core posts an anonymous id, its version and the class names of the LLM, embedding and driver classes it was handed to its vendor's endpoint on every graph it constructs, and an instance whose claim is that extraction is the only sink outside your perimeter cannot ship with a second one on. Setting GRAPHITI_TELEMETRY_ENABLED in the process environment yourself outranks this line, the same way SEMAPHORE_LIMIT outranks the concurrency ceiling — reaching for the library's own name is a decision, not an accident.

The graph beside it is the other half, and it is not a revetly.toml setting because it is not this engine's process: Neo4j reports its own usage under dbms.usage_report.enabled, and its browser and drivers under client.allow_telemetry, both on by default. The compose file revetly init writes turns both off; a graph you run yourself is yours to set, on the graph service, and takes effect on a recreate:

    environment:
      - NEO4J_dbms_usage__report_enabled=false
      - NEO4J_client_allow__telemetry=false

revetly doctor reports both — the library's ping and, by asking the running server rather than reading your compose file, the graph's.

[backfill] — the only pair that bounds a backfill. Both default to 0, which is undeclared, and --run refuses rather than reading that as no limit:

[backfill]
ceiling_episodes = 500
ceiling_spend = 25.0

[pricing.models] — what this instance actually pays. Per million tokens, matched by prefix, longest first; what is declared here outranks the engine's own table:

[pricing.models."gpt-4.1-mini"]
input = 0.40
cached_input = 0.10
output = 1.60

[pricing.models."text-embedding-3-small"]
input = 0.02

[update] and [updater] — updating this instance in place. Declaring [updater] at all is what turns the endpoint on, because what it turns on is a container holding a Docker socket:

[update]
source = "https://downloads.example.com/revetly/"

[updater]
port = 9713
expose = "loopback"              # or "all"
swap_timeout = 120

[engine] — where the engine source is, for the one instance whose root is not the engine's own checkout. Undeclared means src/revetly under the root, which is what every instance has had; declaring it keeps the stale-image check alive for an instance that sits beside the engine repo rather than inside it:

[engine]
source = "../revetly-engine/src/revetly"

Under the container transport that directory has to be mounted into the engine service at the path it resolves to, or revetly doctor warns that the comparison is not being made and names where it looked.

[auth] — who a served endpoint answers. Read by revetly serve --http and by nothing else. An instance that serves stdio, or loopback behind a tunnel, declares none of this; anything else refuses to start without it:

[auth]
mode         = "oidc"                          # or "static": declared tokens only
issuer       = "https://auth.example.com"      # who signs the tokens
resource     = "https://revetly.example.com/mcp"   # what this endpoint calls itself
audience     = "revetly-engine"                # what a token's `aud` must carry
groups_claim = "groups"                        # the claim a grant matches on
verifier     = "introspection"                 # how a presented token is checked
scopes       = ["revetly.read"]                # optional

[auth.introspection]
endpoint          = "https://auth.example.com/oauth2/introspect"
client_id         = "revetly-engine"
client_secret_env = "REVETLY_AUTH_CLIENT_SECRET"   # the *name*, never the value

Two verifiers ship, and an instance picks by what it will spend per request. introspection asks the issuer about every token — nothing cached, so a withdrawal takes effect on the next call. jwt checks the signature locally against the keys the issuer publishes:

[auth]
issuer   = "https://auth.example.com"
resource = "https://revetly.example.com/mcp"
verifier = "jwt"

[auth.jwt]
# every key optional — discovery finds the key set, and the rest are the
# defaults spelled out
jwks_uri   = "https://auth.example.com/keys"   # only where discovery names none
algorithms = ["RS256"]                          # asymmetric only, always
key_ttl    = 600                                # how long a fetched key set stands
leeway     = 60                                 # clock skew tolerated on exp

No network call per request, and a revocation that waits for the token to expire — which is the trade, and why an instance choosing it keeps access tokens short. A key set that cannot be refreshed keeps serving what it already holds, because somebody else's outage is not this engine's refusal; the engine says which of those two states it is in rather than answering identically from both.

Who a served call is attributed to follows from that and needs no config. The verified claims bind to the request, so REVETLY_REVIEWER is read only where there is no bound caller — the stdio path — and never on a request path. Every served call also appends one line to .revetly/audit.jsonl naming subject, issuer, tool, project and outcome: instance-local, gitignored, never pushed, and never committed, because an audit trail that inflates the records repository is one an operator turns off.

And every record says which of the two it was. A served record and a stdio record are otherwise indistinguishable once written: both carry a name, one checked against an issuer's published keys and one read out of REVETLY_REVIEWER, which whoever runs the server can set to anything. So each one carries attribution — verified, with attribution_issuer naming who checked, or self-asserted — recorded where identity is resolved rather than at each writer, and served beside provenance and authority.

This is a label and not a fix, deliberately: an engine cannot make the machine it runs on unforgeable, and refusing to write on the stdio path would break the single-operator case. The stdio write lands, marked. A record written before this existed reads unknown, never self-asserted — an absent stamp is not evidence of a weak one.

audience and groups_claim are two of the three lines that change when this instance is repointed at another issuer, and issuer is the third. audience is what a token's aud must actually carry, which is not always the resource identifier — most authorization servers stamp an audience their own configuration names, and that is a client id at least as often as it is a URL. Left out, it is the resource, which is what every declaration written before this key existed meant. groups_claim (default groups) names the claim a grant matches on; what an issuer calls it is the issuer's business, and there is no issuer-specific branch anywhere in the engine to follow it.

[auth.grants.<claim value>] — what a caller may reach. The identity provider owns who somebody is; this instance owns what they may read, and the permitted set for a call is the intersection of the two. A grant can only narrow: it cannot open a namespace the project never exported.

[auth.grants.engineering]      # a value of the groups claim
role     = "developer"         # the lens — what a role may see, declared elsewhere
projects = ["platform"]        # the namespaces it reaches

Exactly two keys, and anything else in the table is refused by name. A grant naming a project this instance does not declare is revetly doctor's finding rather than a load error — one mistyped project name must not stop ingest, capture and every read, none of which authenticate anybody — and so is the other direction, a declared project no grant reaches, which answers a served read exactly the way an empty corpus does.

projects names namespaces in the vocabulary imports uses — a project or a shared namespace — and nothing is inherited: a grant naming platform reaches a shared namespace platform imports only if it names that namespace too. Declaring the table at all is a decision about everybody: a caller no grant names reaches nothing, a static token declaring no grant included, while an instance with no table narrows nobody. What the intersection does at each read surface is multi-project.md.

[auth.tokens.<name>] — a token this instance declares itself. The escape hatch and the only credential here that authenticates a machine: no browser, no issuer, and it works when the authorization server is down.

[auth.tokens.ci]
hash    = "sha256:<hex>"       # `printf %s "$TOKEN" | shasum -a 256`
expires = "2027-01-31"         # compulsory; a bare date is the whole of that day, UTC
grant   = "ci"                 # names an [auth.grants] entry

The keys that would hold the token itself — token, value, secret — are refused by name, because this file is committed. expires is compulsory: a token with none outlives whoever minted it, and this engine holds no revocation list to make up for that. A declared token is checked by the static verifier before the issuer's, since it costs a hash rather than a signature; mode = "static" accepts nothing else and needs no issuer at all. Whatever presents one is attributed as a machine — attribution_actor: machine beside the trust label, so a delivery a CI job recorded never reads as one a person decided.

revetly doctor covers the whole of it. Discovery reachable, keys fetched, the audience it would pin, clock skew where the issuer sends a Date header, every grant against every declared project in both directions, and whether the issuer's RFC 7591 registration endpoint accepts an unauthenticated request — reported as a finding with its remedy and never as a failure, since a hosted chat client cannot connect without dynamic registration at all. revetly doctor --token <bearer> answers the one question none of those can: whether this token would get in, and as whom. The token is checked and never printed.

Which authorization server issues those tokens is not the engine's, and none is scaffolded into an instance: revetly init --auth writes this declaration and nothing that runs one. Any conformant OIDC issuer satisfies it, and revetly init --issuer-kit <path> writes a runnable one outside the instance for an operator who has none. revetly manual show serving-people is the whole surface — the contract an issuer must satisfy, including the revocation window a short access token implies and what a cloud-hosted chat client needs that a local one does not. What a client does with the discovery document this publishes is mcp-client-surface.md.

[projects.<key>], [shared.<key>] and [instance] — more than one project in one instance. The whole surface is multi-project.md; the shape is below.

A connection is established once, and scope is per project. A project may configure any connector entirely alone — the token variable, the host and the board in its own [projects.<key>.connectors.<name>] — and nothing at instance level is required for anything. Where the instance does hold a table for that connector, a project that states none of the connector's declared connection keys takes exactly those from it and no others; one that states any of them uses its own connection whole. Everything else in an instance-level table is scope, is inherited only by a project that restates nothing at all, and fails revetly doctor for each such project on an instance declaring two or more: alpha: instance scope [connectors.todoist] names what to read — account, project — and alpha declares no [projects.alpha.connectors.todoist] of its own, so it reads them. Which keys are the connection is the connector's to declare and never the engine's to infer — SettingsDeclaration.connection, in writing-a-connector.md §4b. On an instance declaring one project none of this applies: its tables are that project's own.

Two complete files

One project. Everything an ordinary instance needs, and nothing else:

# revetly.toml — one project, its own repo, writing to its own files.
[project]
group_id = "acme-platform"

[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"

[docs]
roots = ["docs"]

[connectors]
enabled = ["pm", "docs", "conversations", "deliveries"]

[backend]
pbi = "repo"                   # approved drafts land as files in this repo

[capture]
ingest = "background"            # a write tool returns before extraction ends

[watch]
interval = 300                   # `revetly watch` reads changed sources every 5m

Several projects and a shared namespace. Each project is its own namespace, its own root, its own ledger and its own bill. Nothing crosses unless both sides declare it, and the shared namespace is a third namespace rather than a merge of two:

# revetly.toml — an agency instance: two clients and one place for the
# decisions that are true for both.
[instance]
default_project = "platform"     # required once there is more than one

[project]
group_id = "acme-platform"       # the default project's own namespace

[routing]
pbi = "sources/backlog"
conversation = "sources/conversations"
delivery = "sources/deliveries"

[connectors]
enabled = ["pm", "docs", "conversations", "deliveries"]

[backend]
pbi = "repo"

[projects.platform]
group_id = "acme-platform"
root = "."                       # this repo
exports = ["decisions"]          # may write into the shared namespace

[projects.beta]
group_id = "acme-beta"           # never inherited, never shared
root = "../beta-client"
imports = ["decisions"]          # may read the shared namespace

[projects.beta.connectors]
enabled = ["pm", "docs", "todoist"]

[projects.beta.connectors.todoist]
# A re-stated settings table REPLACES the instance's whole table — every key
# this one does not name is no longer in force for `beta`.
account = "beta-client"
token_env = "BETA_TODOIST_TOKEN"
project = "Delivery"

[projects.beta.backend]
pbi = "todoist"                # beta's approved drafts land on beta's board

[projects.beta.routes]
conversations = "decisions"      # this source's notes land in the shared namespace

[shared.decisions]
group_id = "acme-decisions"      # its own namespace, distinct from both projects
exports = ["platform", "beta"]   # who may read it

[projects.legal]
group_id = "acme-legal"
root = "../legal"
isolated = true                  # both directions closed; naming it elsewhere
                                 # is a config load error

revetly doctor --all reports what each project resolved to after inheritance, including the keys a re-stated table dropped, before anything bills.

Remaining local-only assumption (documented requirement)

Bind-mounted instance repo including .git. The MCP write tools (submit_draft/submit_revision, set_status, record_conversation) autocommit source files to the instance repo via the git subprocess. The engine therefore requires the instance root mounted at /project to be a real git worktree, not loose sub-dirs. This is not removed — it is an explicit, declared requirement of any deployment target:

Out of scope (later work)

Standing up the managed runtime (Cloud Run/Fly), the Workers control plane, secret custody, and multi-tenant orchestration. This story only guarantees the engine is ready to be dropped into either target unchanged.