Manual 0.8.0

When the ledger and the graph disagree, and how the corpus comes back

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.

What every pass checks

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:

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.

The stamp on every episode

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.

Rebuilding the ledger from the graph

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.

Snapshots

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.

Restoring one

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:

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.

Re-extracting what the graph lost

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.

On an instance declaring several projects

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.

Beside a running engine container

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.

What this does not do

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.