Who this is for: whoever runs an instance and is about to move a host, prune a volume, rename a compose project, upgrade the database, or has just lost a ledger or a graph. You need: a shell on the instance; every command here bills nothing except the one that says so before it does. Not here: what a stale corpus is or what the corpus revision refuses (failure-and-staleness.md), and how projects and namespaces sit beside each other (multi-project.md).
CONTEXT is two records of one corpus. The ledger — manifest.db under the
project's .revetly/ — says what was sent: an episode uuid and a content hash
per artifact, and what the episode cost. The graph holds what came back:
the episode with its body, the entities and facts extracted from it, and the
links the engine declared. Every read is answered from the graph. Every decision
about what to send is made from the ledger.
They can disagree. A graph volume that is reset or replaced — a compose project
renamed, a docker volume prune, a host move, a redeploy — leaves a ledger that
still says every artifact was sent, and because identical bytes are never
re-extracted, nothing would ever send them again: the pass reports zero
episodes, the corpus reports current, and every read answers from nothing. The
other way round, a ledger lost beside an intact graph reads every artifact as
new, re-extracts all of it at the provider's expense, and mints a second set of
episodes beside the first.
This page is what the engine does about that. Nothing on it calls a provider except the one command whose whole point is to price a re-extraction before it happens.
At the start of every read pass — before the diff, and free — the engine looks
up each live ledger row's episode in the graph by uuid, in batches. A row whose
episode is absent is graph_missing. The pass reports those rows by
connector, still runs for everything else, and does not re-send them by itself:
the ledger's hash gate still says the body is unchanged, and re-extraction is a
bill somebody orders (below).
The check is by membership, never by count. The graph deliberately keeps the episodes of earlier revisions of an artifact and the episodes of artifacts their source has since withdrawn; a graph holding more episodes than the ledger names is the normal state of a corpus with history, not a divergence.
The answer is recorded beside the passes in .revetly/ingest-health.json, so
the read surface can report it without touching the graph:
context_health answers corpus: divergent while any live row lacks its
episode, with the count per connector, when it was checked, and fix naming
the two ways out. It goes back to current by itself once the gap is closed
— the next pass, a restore or a rebuilt ledger each re-check and re-record.revetly status prints the same line under the artifact list.revetly doctor carries one check, ledger and graph, that asks the graph
live: PASS when every live row has its episode, FAIL naming both counts and
the two ways out when it does not, and FAIL naming revetly rebuild-ledger
when it finds episodes in the graph beside no ledger at all. It bills nothing
and is skipped under --offline.divergent is stronger than stale. A stale corpus is behind its sources and a
pass fixes it; a divergent one answers from less than it says it was sent, and
no pass fixes that by itself.
From corpus revision 5 every episode the engine records carries the ledger row
that sent it, as properties on the Episodic node under a revetly_ prefix:
the artifact id, connector, kind and type, source path and times, identity
triple, content hash, and the spend the episode cost. It is written at record
time, at no provider cost, and it never moves the content hash — so nothing is
re-extracted to gain it.
An episode that carries no stamp is healed at the start of the next pass from
the ledger row it still has. revetly migrate offers the same healing as the
step from revision 4 to 5, so a corpus written before the stamp existed can be
brought across on purpose rather than waiting for a pass: it is a batched
property write, no episode is sent, and an engine at revision 5 reads a
revision-4 corpus as it stands in the meantime.
The stamp is what makes the ledger a derived record in fact and not only in principle.
revetly rebuild-ledger # one project; --project NAME on a multi-project instance
revetly rebuild-ledger --force # over a ledger that already holds rows
For a ledger that was lost or wiped beside an intact graph. The command reads every recorded episode in the project's group, keeps the latest row per artifact (the graph holds earlier revisions; the ledger holds one row), marks as deleted the ones whose episode the deletion pass closed out, and writes the rows back. No provider is called, and the pass that follows sends zero episodes for anything the graph already holds.
It refuses over a ledger that holds rows unless told --force, in which case
the graph's rows are written over what is there. Episodes that carry no stamp
contribute nothing and are counted in the report; their artifacts read as new on
the next pass and are re-extracted then, which is the honest outcome for a row
the graph could not vouch for.
Artifacts that were withheld from the provider have no episode and cannot be rebuilt from the graph. The next pass meets them again and withholds them again, at no cost.
revetly snapshot # every declared project, one snapshot each
revetly snapshot --project NAME --label before-host-move
revetly snapshot list
A snapshot is one directory under the project's state directory:
.revetly/snapshots/<stamp>/
snapshot.json what this is: project, group, corpus revision, engine version, counts, size
nodes.jsonl.gz every node of the group, with every label and every property
edges.jsonl.gz every edge leaving a node of the group, and every edge arriving from outside it
manifest.db a consistent copy of the ledger, taken through SQLite's own backup
Every property travels with its type — a timestamp that left as a Neo4j
DateTime comes back as one, embeddings come back as the lists of floats they
are — so a restored graph is the graph that was snapshotted, not an
approximation of it. Embeddings are most of the size.
A snapshot takes the pass lock, the same lock every read pass holds. A
dump taken while a pass is landing would hold a ledger ahead of its graph, which
is the exact divergence this page exists to name; so a snapshot refuses, naming
the running pass, rather than waiting or racing it. Run it again when the pass
has landed. (revetly migrate --apply takes the same lock and waits for the pass,
because the operator may just have confirmed the re-extraction it priced.)
Snapshots are manual, local and free. Nothing schedules one, nothing prunes
old ones, nothing pushes them anywhere, and taking one calls no provider. They
live under .revetly/, which is derived state the instance ignores in git —
they never reach the records repository or any remote. To forget a snapshot,
delete its directory. Take one before the events that lose graphs: a host move,
a volume prune, a compose rename, a database upgrade, a deliberate reset. The
--label is for saying which.
revetly restore <stamp> # --project NAME on a multi-project instance
revetly restore <stamp> --replace # clear what the group holds first
Restore replays the snapshot's nodes and edges into the project's group with
their uuids, labels and property types preserved, rebuilds the graph's indices
and constraints, puts the snapshot's manifest.db in place (the ledger it
replaced is kept beside it as manifest.db.replaced-<time>), re-checks the
ledger against the graph, and records the answer. It calls no provider, and the
pass that follows sends zero episodes.
Three requests are refused before anything changes:
--replace says to clear it
first — which does what revetly reset does to the graph, and then replays.An edge that arrived from outside the group (a shared namespace, say) is replayed only when its other endpoint is present; the ones that are not are counted and reported, never invented.
A restore is thousands of small transactions, and edges carry their embeddings.
A database given little memory can drop the connection under them; the restore
waits for it to come back and retries the batch, because every statement is a
merge by uuid and running it again lands only what the first attempt did not.
If it still fails, run it again with --replace.
revetly migrate --repair-graph # price it; change nothing
revetly migrate --repair-graph --apply # mark exactly those rows for the next ingest
The priced direction, for a corpus that has no snapshot to restore. It asks the
graph which live ledger rows have no episode, prices re-extracting exactly those
with the same instrument that prices a pass, and — only with --apply, and
after asking once more — clears their recorded hashes so the ordinary next
revetly ingest re-sends them and nothing else. It moves no corpus revision and
touches no other row. Rows whose source no longer offers the artifact cannot be
re-extracted and are named; the next pass records them as withdrawn where its
source's view is whole.
Every project keeps its own ledger under its own state directory and writes its
own group, so every gesture here is per project. revetly snapshot and
revetly snapshot list take every declared project by default; revetly restore, revetly rebuild-ledger and revetly migrate --repair-graph name
one with --project, as every destructive command does. A snapshot never
crosses a group, and a restore never reads another project's state directory.
See multi-project.md for how projects and namespaces sit
beside each other.
revetly snapshot, revetly restore, revetly rebuild-ledger and revetly migrate each open a project's ledger. Typed on the host while the instance's
engine container is running, they refuse before touching it and print the same
command through docker compose exec -T revetly, because SQLite's locks do not
cross the bind mount and a host process holding manifest.db can fail the
container's pass. Run what they print, or, with the watch loop stopped, put
--on-host first (revetly --on-host snapshot). revetly snapshot list
reads only the snapshots directory and runs anywhere.
failure-and-staleness.md
has the rule.
It does not schedule snapshots, keep a retention policy, or push a snapshot to any remote — each of those is a decision about cadence or egress that belongs to whoever runs the instance, and a later release may offer one as a declared option rather than a default chosen in silence. It does not restore into a different group. It does not replace the database's own dump and backup tooling, which knows nothing about groups, ledgers or corpus revisions and is still the right tool for the whole server. And it does not re-extract anything without pricing it first and being told to: see failure-and-staleness.md for what a stale corpus is and what the corpus revision refuses.