Who this is for: one operator running several projects — repos, engagements, product lines — from one engine. You need: a working single-project instance first; every key below is an addition to the one it already has. Not here: multi-tenancy, which this is not (read The grade of the guarantee below), and the per-key reference (engine-config-surface.md).
One engine, more than one project, and references that cross between them.
This is one operator's several projects inside one trust boundary — a platform repo, a product repo and an infrastructure repo that keep separate backlogs and keep referring to each other's decisions. It is deliberately not multi-tenancy and must never be reached for as one: the rule for tenants is one engine instance per tenant, with its own store and its own key, and nothing on this page weakens it. Read The grade of the guarantee below before you put somebody else's data beside your own.
An instance that declares one project — which is every install written before
this — behaves exactly as it did: same ids, same namespace, same .revetly/,
same responses, no new required key, no re-ingest and no migration step.
[instance]
default_project = "platform" # who answers a caller that names none
[projects.platform]
group_id = "acme-platform" # its own graph namespace, always stated
root = "platform" # its own folder, inside this instance
imports = ["product", "decisions"]
exports = ["product"]
[projects.product]
group_id = "acme-product"
root = "product"
imports = ["platform"]
exports = ["platform"]
[projects.client-acme]
group_id = "acme-client"
root = "clients/acme"
isolated = true
The table name is the project key. It is written by hand and read from nowhere
else: not from a directory name, not from a path, not from the namespace string.
That is the same rule group_id follows — injected and pinned, never guessed —
one dimension up: a namespace is never guessed, and neither is reach into one.
A project may re-state any section the instance states, and inherits the ones it does not:
[projects.platform.routing]
pbi = "backlog"
[projects.platform.connectors]
enabled = ["pm", "docs"]
A section is read key by key: [projects.platform.connectors] enabled shadows
the instance's enabled and leaves the rest of [connectors] alone. A
settings table under one — [connectors.todoist], [backend.repo] — is a
single key, so re-stating it replaces the whole table.
That is deliberate. Merging would leave no way to unset an inherited key, and this engine refuses silent winners between two declarations everywhere else. But it is worth reading twice before you write one, because the keys you leave out do not survive:
[connectors.todoist]
account = "smg" # the Todoist account these ids belong to
token_env = "TODOIST_API_TOKEN"
project = "Roadmap"
[projects.beta.connectors.todoist]
project = "Beta board" # and NOTHING else is now in force here
beta no longer declares account, so the Todoist connector falls back to that
project's own group_id and mints todoist:acme-beta:<task id> where its
sibling mints todoist:smg:<task id>. A different source_instance on every
artifact of that board, from an omitted line — and undoing it once artifacts
exist is a rehome plus paid re-extraction.
Nothing about that is silent any more. revetly doctor reports the
settings each project resolved to after inheritance, names the keys a
re-stated table dropped, and reads an identity fallback as a warning rather than
a pass; revetly ingest --dry-run names the same keys for the project it is
about to pass over. Both say it before anything bills.
A settings table has two halves and only one of them is about this project. The connection is what establishes communication with somebody else's tool — the credential, the account the address needs, the endpoint, the API version, the request timeout. Everything else is scope: which board, which team project, which area path, which folders.
A connector declares which of its keys are which, in its own
SettingsDeclaration (connection, documented in
writing-a-connector.md). Today:
| table | connection keys |
|---|---|
[connectors.todoist] |
token_env, base_url, timeout |
[connectors.azure-devops] |
token_env, organization, base_url, api_version, timeout |
[connectors.github] |
token_env, base_url, api_version, timeout |
[connectors.commits], [connectors.docs] |
none — their connection is [git.credentials] and the filesystem |
On an instance declaring two or more projects, that split decides what a project inherits:
So the shape that is actually useful at instance level is a connection and nothing else, and each project states what it reads through it:
[connectors.todoist] # established once
token_env = "TODOIST_API_TOKEN"
[projects.alpha.connectors.todoist] # what alpha reads through it
project = "Roadmap"
[projects.beta.connectors.todoist] # what beta reads through it
project = "Beta board"
Both projects run on TODOIST_API_TOKEN; neither can see the other's board.
Several repositories are not a reason for several projects. A project that keeps
one backlog across GitHub repositories lists them in its own table,
repos = ["acme/platform", "acme/platform-infra"], which is scope like repo;
each repository's issues keep that repository's own ids
(connect-github.md).
beta may take the connection back at any time by naming a variable of its own —
a project can always configure a connector alone, key and all, and nothing at
instance level is required for anything.
Stating a connection key means stating it whatever the value. token_env = ""
in a project's table is that project saying its connection is its own and
deliberately blank; the way back to the instance's is to remove the key, not
to blank it.
An instance-level table that still carries scope is the single-project shape left
behind. It reaches every project that declares no table of its own, and each of
them would read the first project's sources into its own namespace: a second
paid copy of the same artifacts, and a backlog it writes into. So on an instance
declaring two or more projects revetly doctor fails its instance scope
line once for each project that inherits scope, naming the table, the keys and
the project. The line is that project's, so revetly doctor --project NAME
reports only NAME's inheritance, and revetly ingest --project NAME --dry-run
prints the same sentence as an INHERITED SCOPE: line before anything is
priced. The fix is to move the scope into the own
[projects.<key>.connectors.<name>] of the project it belongs to and leave the
connection at instance level.
The configuration page offers that move when a project is added. On an instance
whose connector tables carry scope, the folder step of Add project shows
Shared source settings with the tables and keys, and a switch, ticked, that
moves them into the existing project's own table in the same save. The existing
project then resolves exactly the settings it resolved before, so nothing it has
read changes identity and its next dry run reads nothing new and nothing
changed. revetly doctor --project and revetly check also say, per source,
whose connection it runs on: its own or the instance's.
On a single-project instance none of this applies. The instance's tables are that project's own, so there is nobody to share a connection with, its instance-level scope is valid, and every install loads exactly as it did.
Two projects may not share a group_id and may not share a root. Both are
config load errors. The root matters because each project keeps its own ledger,
health record, freshness record and lock under it — which is why declaring a
second project moves nothing belonging to the first, and why a pass over one
project neither blocks nor folds into a pass over another.
A project's root holds its records and its state, and nothing else: its code,
documents and tracker are read through connectors ([connectors.commits] repos,
[docs] roots, a tracker table), wherever they are. So a project's root is a
folder inside the instance — the configuration page's Add project defaults it
to the project key and refuses a folder outside the instance.
Its conversation notes, decision notes, delivery records and repository-backed
backlog live under that folder, in the instance's records store. With
[git] records_clone = "." declared once at instance level, a project at
root = "platform" writes platform/conversations, platform/deliveries and
so on, and those writes are committed to and pushed from the instance's records
clone — the same clone and the same push as the project at the instance root.
An inherited records_clone resolves against the instance, not the project;
a project's root inside the instance is mirrored into the store, and a root
outside it is given a folder named for the project key. A project that declares
[projects.<key>.git] records_clone itself keeps the older reading, relative to
its own root, with its routing relative to that clone.
Two projects may not share a record folder either, or nest one inside the
other's. The loader compares the resolved conversation, delivery and
repository-backed backlog folders — so a .., a records_clone or a routing
override cannot hide a collision — and refuses the file naming both projects,
the kind and the folder, as it does for a shared root. The page's save and the
supervisor's reload refuse the same file in the same words. revetly doctor
prints each project's resolved record folders, so a near miss is visible first.
An instance written before this, with a project rooted outside the records store
(root = "../platform"), keeps reading that project's records where they are.
revetly doctor --project platform names every record file that would move, and
revetly records migrate --project platform --dry-run # what would move
revetly records migrate --project platform # move it
moves them into the project's folder in the records store with their git
history: git mv inside one repository, the files' own commits replayed into
the records clone across two. The old repository gets one local commit removing
them, never pushed. Nothing moves until the command runs, and a moved record
keeps its id and its bytes, so the next pass reads it unchanged and re-extracts
nothing.
What belongs to the instance stays at the instance root, in the .revetly/
beside revetly.toml: the update endpoint's token and its job records, and the
configuration copy the page keeps for a rollback. Those are one
directory on an instance declaring one project and a different one here, so the
distinction is only visible from this page onward — a credential the whole
instance is updated through filed under one project would be a credential its
other projects could not present.
revetly projects prints what an instance declares, including what each project
actually resolves to read. revetly audit-links --project NAME / --all does
the same for the graph half — each project's ledger judged against its own
namespaces, because every resolution behind those checks goes through the
resolver and outside a running pass the resolver answers with the default
project's groups. revetly doctor --project NAME runs the whole
pre-flight chain for one project — its namespace, credential, resolved scope,
write target, backlog shape, declared history and the git health of its own root
— and revetly doctor --all runs it for every project the instance declares.
Naming neither checks the default project, which is what the command has always
done. The report is per project; the exit status is the instance's.
revetly config writes exactly the restatement described above, through one
gesture rather than a text editor. A project's card carries an Overrides
band listing what that project already restates of the instance's own
settings.
An override is edited where it is read. Each row in that band is the
scoped field — [projects.<key>.<section>] <key>, drawn as the option's own
control, with the same type, the same unit and the same accepted values it has
anywhere else on the page. What the project reads is in the box; what the
instance still reads under it is the grey behind it and the instance <value>
reading beside it; and Remove override is the one way back to inheriting.
Typing a new value into that box stages the scoped key and the one Save writes
it — the same key, through the same writer, as choosing the topic from Add
override below. The row used to be two readings, and the only way to change a
value was to remove the override and add it again — the long way round for the
one row on the card that is about this project having an answer of its own.
Add override, behind the page's Advanced settings switch, offers exactly
five sections and nothing else: Extraction and spend, Sensitivity and
withholding, Watch and retries, Git and records, and Document folders. Never
[llm] or [embedding] — the loader refuses either inside a
[projects.<key>] table outright, restatement or not. Never Authentication
and approval or Ports, updates and containers, because the engine reads both
once for the whole instance rather than once per project, so there is
nothing there for a project to restate. And never a [connectors.<name>]
settings table: the picker offers instance sections, which resolve to a
handful of keys the page already knows how to render, and a component's own
settings table is a shape nothing on this page was ever told — minting one
from a picker would be guessing at fields no declaration named. A project
that already restates one — by hand, or because a connector shipped with
settings of its own — keeps rendering it exactly where it always has, on
that source's own card, carrying the sentence above: a re-stated table
replaces the instance's, and it does not merge into it. The page repeats
that sentence rather than hiding the mechanism behind a friendlier one,
because it is exactly what somebody editing the table by hand needs to know
before they leave a key out.
Choosing one writes a scoped declaration for that project alone — one
project's table, one atomic save through configwriter, validated by the
same loader any other save goes through — and every other project keeps
reading the instance's own value untouched. Removing an override removes
the scoped declaration rather than writing an empty one back. A table
written and then cleared of its last key is a table that no longer differs
from the instance, so the page deletes it rather than leave an empty
[projects.<key>.watch] sitting in the file, cascading upward through any
table clearing left with nothing else in it — never [projects.<key>]
itself, because a project with no keys left is a project being deleted,
which is a different act. Restating a value, removing it and restating it
again round-trips to the file it started from, byte for byte.
A project restating nothing shows No overrides in the band, in the open, rather than a heading with nothing under it — the same rule the Instance settings sections below it follow.
The engine reads sources per project: [projects.<key>.connectors] enabled is
the list that project reads, and [projects.<key>.connectors.<name>] is that
source's settings for it, each replacing the instance's when declared. The
Sources band on a project card is the editor of that list, and on an
instance declaring two or more projects it writes the project's own row and
never the instance's.
Add source appends the name to the list that project reads today — its
own where it has one, the instance's where it does not — and writes the result
as [projects.<key>.connectors] enabled. The source's settings go under
[projects.<key>.connectors.<name>], with one exception: where the instance
already declares [connectors.<name>], the project inherits that table and the
save writes nothing but the list. That exception is the replace-not-merge rule
above, read forward — copying the instance's table down would start this
project on a second copy that drifts from the first the next time somebody edits
one of them.
Remove source writes the same list without the name, and drops
[projects.<key>.connectors.<name>] where that table was this project's own —
the whole table, keys and all, because a scoped settings table exists only for
the source it configures.
Removing a source the project inherits is the one place this page writes a
declaration nobody typed: it restates [projects.<key>.connectors] enabled for
that project without the name. That is safe by the rule at the top of this
document — [connectors] is read key by key, so a scoped enabled replaces
only the instance's enabled and only for this project — but it is still a new
line in somebody's file, so the card says so before it happens, in one sentence
under its sources. Adding the source back does not take that declaration off
again: the list is the project's from then on, holding what it reads. The
instance's [connectors] enabled is never written by either gesture, and every
other project goes on reading exactly what it read before.
On a single-project instance nothing changes. A bare [project], or one
declared project reading the instance's tables, is the instance for this
purpose: [connectors] enabled is that project's list and [connectors.<name>]
is that source's settings, so both gestures edit them where they are. Writing a
scoped copy there would mint a table nobody asked for over a value nothing else
reads. On an instance declaring several projects the instance's own
[connectors] enabled is the list a project reads until it says otherwise, and
it is edited under All settings — no card claims it, because no card is
allowed to write it.
Where the file declares two or more projects, Instance settings gains a
Connections section. One line per external tool the instance holds a
[connectors.<name>] table for — Todoist: token set · api.todoist.com,
Azure DevOps: contoso · dev.azure.com — each with Test connection and a
Change that opens that connector's connection keys and only those. 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 All settings is where they are edited while revetly doctor names who is reading them. Add connection offers the registered
external connectors that declare connection keys and have no table yet;
choosing one paints those fields at instance level, and Save writes
[connectors.<name>] with what was filled in. Nothing there writes
[connectors] enabled.
The section is drawn only above two projects, and only where a connection exists or the Advanced settings switch is on — an instance with none holds it behind the switch rather than not drawing it, because Add connection is the one gesture that establishes the first one. On a single-project instance the section is never drawn and every source card is exactly what it was.
On a project card of a multi-project instance, Add source for a connector that declares connection keys offers two answers where the instance holds a connection for that tool, and one where it does not:
Each source's line then reads which connection it is on — Todoist: Roadmap ·
comments read · the instance's connection, or · own connection — and Test
connection on it probes with that connection in force: the button carries the
project it is about, so a check on beta's card is answered about beta's
chain and not about whichever project the instance answers by default.
Where the file declares two or more projects, revetly config moves the
instance-level Records folders, Approved item store and Item types and
parents — Backlog id family and Id prefix per type among them — plus Default
project out of any one project's card and into their own Project
defaults section at Instance settings — the one editable control for a
setting every project reads, rather than a copy on each card that could
silently drift from its siblings. Each project's own card still shows the
Records folders line and the Approved item store and
Item types and parents bands with those rows, but read-only, chipped
Applies to every project and carrying no control of its own — there is
exactly one place to change a setting every project reads, and it is not any
one project's card. Default project itself stays only in the Project
defaults section, since which project answers an unnamed call is a fact
about the instance rather than about any one project. On a single-project
instance nothing moves: those rows stay editable on that project's own card,
because there is not yet a second project for them to be "every project's"
version of.
A shared namespace draws no project card of its own — it is not a project —
and no longer renders as an unlabelled line on the instance card either.
Where one is declared, it is its own named line at the Projects level of the
page, reading <key> · read by <exports>, with Change opening its two
rows: the namespace's own group_id, and the exports naming which
projects may read it.
Sharing is default-deny and two-sided. A project that declares nothing exports nothing and imports nothing. Content crosses only where the exporter names the importer and the importer names the exporter — a one-sided declaration shares nothing, so reach is always something two people had to write down.
Direction is two words rather than one flag because one flag cannot say it. A client project may need nothing of its own to leave while happily reading the shared platform decisions; an audit-facing project needs the opposite, so its answers are provably drawn from its own corpus alone.
isolated = true is the fourth position, and the only one that is enforced
rather than offered. An isolated project declares no imports, no exports and no
routes, and any other table naming it — in either direction, including a shared
namespace's exports — is a config load error naming both. Not a line
quietly ignored: a permission model whose deny case is "nobody wrote a share
rule" fails the first time somebody edits the other project's file, and the way
it fails is an answer nobody sees is wrong.
revetly doctor asserts it rather than reading it back. For each isolated
project it walks the whole roster for anyone naming it and resolves its read set
to compare against its own namespace, and fails the check when either is untrue.
Every graph read and every graph write takes its group set from
revetly/scope.py, and no call site assembles one of its own. That is the whole
enforcement design, and it is the design because the alternative was measured:
permission expressed as "each call site remembers to filter correctly" fails
exactly once, silently, and is discovered by reading an answer that should not
have existed.
The clauses that carry it are in one place as well. Every artifact-row query —
which artifacts are in scope, which nodes stand for one, what a declared edge
reaches — and every statement read — what binds a scope, what a note states,
what revises what — is a named statement in revetly/rows.py behind one store
seam, so the group clause on each of them is read off one file and held against
a real Neo4j rather than against a test double alone.
Spanning costs nothing extra. Graphiti's read surface already takes a list, and
on Neo4j that is one query with a group_id IN $group_ids filter — the
per-group fan-out in handle_multiple_group_ids is FalkorDB's path, not this
one.
imports/exports state what a project may read. They cannot state what
a person may, and while the only caller was the operator holding the pipe
the two were one question. A served endpoint has callers, and
serving-people.md is where their grants are declared. Here
is what a grant does at the read surface:
The permitted set for a call is the project's declared reach ∩ the caller's grants. Intersection and never union.
Config keeps its veto: a grant cannot open a namespace a project never exported, and a token can only narrow. It resolves at the same resolver everything else does, so every read tool — and every rollup, delta, drift answer, branch and review list built on one — inherits it by not having been written to do anything.
A grant names namespaces in the vocabulary imports already uses — a project or
a shared namespace, by name — and nothing is inherited. A caller granted
platform does not reach the shared namespace platform imports unless the
grant names that too. The inheriting version reads better and is wrong: a shared
namespace is exported to several projects, so reaching one by way of a project
would hand a caller a namespace nobody wrote down for them.
Three states, and they are three different answers:
| what the endpoint has | what narrows a call |
|---|---|
| no verified caller — the pipe, and loopback | nothing; the operator reaches everything config declares |
a caller, and no [auth.grants] |
nothing; config alone decides, exactly as before |
| a caller no grant in a declared table names | everything; that caller reaches no namespace at all |
A caller granted nothing is answered by an empty corpus rather than an error:
"you are granted nothing" is a fact about them, and "there is nothing here"
would be a lie about the instance. And the read says so: the
answer carries a grant key stating which claim that token presented, which
values it carried, and that no [auth.grants] entry here names them — or, where
one does and reaches no declared namespace, that it is that mismatch. It names
no project, no count and no roster, because it is a fact about the caller's own
token and a stranger learns from it only what their token already told them.
Without it the four states a reader cannot tell apart — my grant reaches
nothing, this project holds nothing, ingest never ran, the engine is broken —
arrive as one empty list, and the two keys that would otherwise hint a corpus
exists (context_health and the backfill history floor) are suppressed for
exactly this caller. revetly doctor names both halves of the mismatch — a
grant naming a namespace this instance does not declare, and a namespace no
grant names — and revetly doctor --token <bearer> says the same sentence for
one token before a client is ever pointed at the endpoint.
Every read tool takes project. Named, it answers for that project; unnamed, it
answers for the declared default and says which project answered. A list of
names spans them deliberately, and every result states the project it came from.
Reach depends on what the read asks. A read that answers a question
(search_context, get_authoring_context, get_artifact,
get_scope(question='binding')) reaches what the project imports: that is what
an import is for, since a decision in platform may answer a question about
product. A listing of the project's own work (list_backlog, and
get_scope(question='drift') or 'deltas' over the whole project) reads only the
items the project's own sources hold, including what they route into a shared
namespace, so a project's backlog does not change shape because it may read a
neighbour. A listing that needs the neighbour names both projects. Drift and
deltas rooted at an artifact_id are questions about that artifact and reach the
imports, as get_artifact does.
A backlog read also carries an outside_backlog block: the tracker items no type
map covers, which ingest under their source's own word and stay off the backlog.
It follows the same rule. One project's read carries that project's block, and a
read naming several keys the block by project, splitting the room between them.
What a read volunteers about the corpus is the named project's too. The
freshness stamp, the history floor and the context_health warning are read
off that project's own sources and records, never off the default's, so a read
naming product carries product's floors and none of platform's, even though
product imports platform. A spanning read carries each project's under that
project's name.
For a caller a grant narrows, "unnamed" means their grant rather than the
declared default — which may be a project they cannot read — and the answer says
which project, or which projects, it came from. A project outside their grant
is not an argument to be validated: it answers as a namespace that does not
exist, in the same words a misspelling gets, with a roster listing only what
they may reach. A permission error would confirm the project is there, which is
the one answer a boundary must not give.
The two silences stay told apart. Inside a project a caller may read, an artifact this instance declared never-extracted is present-and-withheld — a reader who cannot tell "nothing" from "something you may not see" reads a corpus with holes in it. Across a grant boundary there is nothing at all: no id, no title, no count, no marker, which is what makes a grant a boundary rather than a hint.
revetly search "retry policy" --project platform --project product
revetly inspect STORY-000 --project platform
revetly status --project product
A reference from one project to an artifact in another resolves to a real
link where the policy permits it: same method, same provenance, ranked ahead
of inferred links exactly as any deterministic link is, and carrying
other_project so a reader knows it is about to leave the project it asked
about.
Resolution runs in one order and the order matters: a node this project recorded
with the id, then a node a permitted neighbour recorded with it, then a node of
this project's merely named for it, then the stub. Extraction mints entities
named after tracker ids out of any prose that mentions one, and a local name
match that outranked a neighbour's real node would bind the claim to that ghost
and never cross — silently, and more often the more two projects quote each
other's ids. A claim minted before that order was settled stays where it landed;
revetly audit-links finds it and revetly ingest re-derives it.
Where the policy does not permit it, the reference stays an unresolved
external-reference — indistinguishable from a reference to something never
ingested. Not the target's id, not its title, not its existence. Existence is
usually the confidential part.
This is deliberately not the present-and-withheld shape, and the difference is the point.
An artifact withheld from the provider is still reported present-and-withheld
to a reader of its own project, who chose the withholding — naming it is honest
to them. Naming it to a reader of another project discloses what
isolation exists to hide. One response shape, two audiences: reuse the
withheld key across the boundary and this is undone without anybody touching
it.
context_health follows the same rule. It answers for the named or default
project, and its projects map carries only projects in that project's
permitted set — an isolated project's name, pass outcomes and corpus state never
appear in another project's report, or the health surface leaks the roster the
read surface refused.
Which sources a project reads, and what they hand back, is a per-project question
in the same way, so the commands that answer it take --project too:
revetly connectors list --project beta # that project's [connectors] enabled
revetly connectors sample acme --project beta # its settings table, its credential
revetly connectors prove acme --project beta # the kit, over the connector that project builds
Each resolves through that project's own [connectors] enabled and its own
settings tables, and the named project is the running one for the whole read
rather than a config that was merely passed. That matters most where it is least
visible: a RawArtifact derives its id against the running project's home
namespace and the PM connector reads its backlog types the same way, both
during extract() — so a sample answered with another project's namespace would
print exactly what it exists to show, the derived id, wrong. Unnamed, all
three answer for the declared default; on an instance declaring one project they
print what they have always printed, down to the line.
A write tool called on an instance declaring more than one project must name one. There is no default for a write: landing a draft, a status flip or a delivery record in a project nobody chose is a write in the wrong corpus, and the corpus is what whoever picks this up next will read — so the mistake is invisible until it has been believed. The refusal names the declared projects.
The working agreement an agent reads says so. On an instance declaring more than
one project, the agreement revetly init wrote carries a Say which project
section naming the projects and the default a read falls back to. It follows the
declaration rather than the moment init ran: every save on the configuration
page, Add project included, rewrites that one section in each agreement file
at the instance root that carries the Revetly marker
(<!-- revetly:working-agreement -->), adds it when a second project is
declared, and removes it when the last-but-one goes. An instance whose projects
are declared by hand runs revetly refresh-agreement, which does the same and
touches nothing else init would. A file without the marker is the project's
own and is never written. revetly doctor fails a multi-project instance whose
marked agreement lacks the section, or names other projects, and names the
command that refreshes it; an unmarked agreement that does not mention the
projects is named as a warning.
Destructive operations are the same. revetly reset requires --project (or
--shared) on a multi-project instance and refuses an unnamed invocation.
It clears the named namespace's group in the graph and its ledger, and with
them every record of passes over that corpus kept in its .revetly/: the pass
record in ingest-health.json (its corpus revision stamp stays), the watch
loop's watch.json and backoff, each source's row and backoff in
sources.json, the backfill checkpoint in backfill.json, whose floors
describe history read into the erased corpus, and a watch.pid whose process
is gone. connections.json
stays, because it records when each source was connected and from which floor,
which is about the sources and not the corpus. After a reset the project reads
as never ingested, and its watch loop waits for a first pass run by hand. A
loop that is running through the reset keeps running, and its next tick
carries no backoff over.
The delivery note is a write too. revetly deliveries post-note <DEL-id> posts
through one project's write adapter from that project's records store, so on an
instance declaring more than one project it takes --project and refuses an
invocation that names none, as every other write does. Whether a
ship posts a note at all is [backend] tracker_note, which a project may set for
itself. revetly check reports delivery-note drift for every project, each line
prefixed with the project's name, because each project holds its own records and
its own tracker.
Which store a project would write into is a per-project question too, so the
commands that answer it take --project like everything else:
revetly backends list --project product # the adapter that project selected
revetly backends rehearse --project product # what it would send, nothing mutated
revetly backends prove --project product # the kit, over the store that project selects
Each resolves through that project's own [backend] pbi and its own settings
table, and rehearse reads that project's credential and that project's ledger —
a rehearsal of the default project's store is a rehearsal of a store that is not
the one under test. prove with no adapter name proves the store that project
selects, and a project declaring [backend] pbi = "" gets the same no-adapter
refusal the instance has always given. Naming none answers for the declared
default, as it always did, and all three say which project answered so a listing
read out of context cannot be mistaken for the instance's only answer.
Reviews are the exception, and on purpose: a review is one operator's own gate
inside one trust boundary, so list_reviews lists every project's (each entry
saying which) and raise_review finds the one holding an id rather than making
a caller who has an id know which corpus it will land in.
Every project this caller may reach, on an endpoint that declares grants. A parked draft carries its title, so listing every project's to somebody granted one would show them the shape of work the read tools just refused; an id out of a project their grant does not name is answered as an id this instance does not have. And a write naming no project stops being ambiguous where a grant names exactly one — the question a multi-project instance refuses is "which of the projects may I write into", and a grant of one has answered it.
An agent working in one project's repository should not have to name that
project on every call. revetly serve --project NAME, over stdio or with
--http, serves a connection bound to NAME, and the binding is declared in
that repository's own MCP configuration:
{
"mcpServers": {
"revetly": {
"type": "stdio",
"command": "docker",
"args": ["exec", "-i", "revetly-engine-acme-platform", "revetly", "serve", "--project", "product"]
}
}
}
The engine container is revetly-engine- followed by the namespace the instance
was created with, as its compose file names it. The entry revetly init writes
reaches it through docker compose exec at the end of a short sh -c script
that first checks the container is up. Copy that entry into the repository and
append --project product after revetly serve at the end of the script. A
host install's entry is "args": ["serve", "--project", "product"].
That is not a default for a write. The rule above refuses a project nobody chose, and a binding is a project somebody chose and wrote down where the agent runs. On a bound connection:
NAME, and every response
states a project, writes included, and on an instance declaring one project
too. It is "project": "NAME", except on an artifact read from a project
NAME imports, which names the project it lives in.bound-to-another-project before the tool runs. It is never widened to
that project. A read still reaches what NAME imports, because that is
NAME's own reach. A deliberate span across projects is a call for an
unbound connection.list_reviews and raise_review see NAME's reviews only.NAME.An undeclared NAME refuses to start and names the declared projects. An
unbound connection behaves as it always has: a write still has to name a
project on a multi-project instance.
A pass is over exactly one namespace and bills exactly that one:
revetly ingest --project platform
revetly ingest --shared decisions
Each project's manifest gates only its own artifacts, so a pass over one project
leaves every other project's manifest state and spend untouched. Each keeps its
own ingest-health.json, so a failed pass over one never reports another's
corpus as stale. Each takes its own ingest lock, so concurrent passes over the
same project stay serialised while a pass over one project does not block a
pass over another.
revetly watch watches one project. Two projects are two loops, and they do not
contend.
A loop never runs a project's first pass. A project that does not restate
[watch] inherits the instance's switch, and the supervisor re-reads
revetly.toml on save, so a project declared on a running instance gets a loop
within seconds. That loop reads nothing until a pass is on record for the
project: each tick asks no source, opens no graph and records that it is
waiting, with revetly ingest --dry-run --project <key> as the command that
prices the pass. "On record" is the project's own ingest-health.json holding a
pass, or its ledger holding a row. revetly ingest --project <key> and the
page's first-pass action both write one; a dry run writes neither. revetly doctor and context_health say per project which loops are waiting. The first
pass is the largest price and the one a declaration that is still wrong costs
most, and it is the moment somebody is certainly at the keyboard, because they
just declared the project.
Visibility is not sameness. Dedup in Graphiti is scoped to the group: a new
entity resolves against candidates fetched with the node's own group_id, and
edge invalidation is scoped the same way. So a decision ingested under the
platform project and referenced from the product project is two nodes with two
uuids, and a fact invalidated in one stays valid in the other. A spanning read
returns both, side by side. That is not a bug to fix in the read layer — it is
what group scoping means, and merging at query time would put a model's guess
where an operator's declaration belongs.
For the small, load-bearing set of values that must be the same everywhere — the operating constraints, the security posture, the decisions that outrank every project's local reasoning — declare a shared namespace:
[shared.decisions]
group_id = "acme-decisions"
exports = ["platform", "product"]
[projects.platform.routes]
conversations = "decisions" # this source's artifacts live there
It is a third namespace beside the projects and never a merge of two. A project's own namespace is unchanged by it existing. Which artifacts go into it is declared on the source that owns them — never inferred from content, path, or which projects happen to reference it. Reading it is a permission like any other, on the same declaration and the same resolver: a project does not acquire it by the namespace existing, and an isolated project can neither read it nor route into it.
It has its own manifest and its own budget line, because a shared namespace is nobody's project and "a pass over a project bills only that project's artifacts" would otherwise be unprovable the moment it exists. Its pass reads every project that routes into it, never one at a time: the manifest is shared, and a pass that saw one contributor would read the others' absence as deletion.
Its artifacts keep the egress and sensitivity policy of the source that owns them. A reading project's policy neither loosens them nor is loosened by them — the body left this instance once, under one policy.
Which namespace an episode lands in is decided before the provider call, not at query time. So changing where an artifact lives means re-extraction: the episode is sent and billed again, and what the old namespace held for it is retired. At the measured 12–35 provider requests per episode that is not a config toggle.
revetly rehome # what moved, and what re-extraction will cost
revetly rehome --apply # retire the old namespace's nodes and edges
revetly ingest --shared decisions
Until that is done, both halves of the move refuse: the pass that would quietly stop seeing the artifact, and the pass that would quietly send it again somewhere else. A corpus holding both copies is the state this refuses to arrive at by accident. Reverting the routing declaration is the other way out, and costs nothing.
This is soft isolation. group_id is a node property inside one Neo4j
database, not a database of its own: the compose file runs neo4j:5.26
(Community edition, one user database) and the client opens with no database
argument. FalkorDB does map a group to a database; Neo4j does not.
What that buys:
What it does not survive:
Hard isolation is a separate engine instance with its own store — already true of how this engine deploys, and non-negotiable for a hosted, multi-tenant deployment: one engine instance per tenant, no shared-process tenant switching, so one tenant's key and plaintext can never reach another. Declaring projects does not satisfy that rule and is not a way to meet it. If the answer to "who would be harmed if this leaked" names somebody outside your organisation, run a second engine.