Backup and restore
In this section
A snapshot is a complete copy of one brain's store, cut at one generation while the brain keeps serving. brain.persist(path) takes it in process. Over brainy host the same door takes a snapshot name, never a path, and the host puts the snapshot inside a folder the operator placed. This page is the operator's side: the folder, the call, the refusals, the disk cost, and the restore.
Place the folder, start the host
mkdir -m 0700 /srv/brainy/persist-snapshots
npx brainy host --brains-dir /srv/brainy/brains --keys /etc/brainy/keys \
--listen 127.0.0.1:8400 --snapshots-dir /srv/brainy/persist-snapshotsBASHThe folder is yours to place: the host never creates it, and it must exist when the door is called. Own it by the user the host runs as, mode 0700.
Put it beside the brains directory. A folder equal to, inside or containing
--brains-dirrefuses to start (SnapshotRootUnusableError): a snapshot inside the brains directory would be served as a brain.Put it on the same filesystem as the brains. On one filesystem a snapshot is mostly hard links and costs almost nothing at the moment it is cut; across filesystems every file is a full copy.
Without
--snapshots-dirthe host serves every other door and refuses this one by name (SnapshotRootNotConfiguredError, HTTP 501).
Take a snapshot
Service key only. Over HTTP:
curl -X POST -H "Authorization: Bearer $SERVICE_KEY" -H 'content-type: application/json' \
-d '{"name":"2026-09-29-nightly"}' \
http://127.0.0.1:8400/v1/brains/notes/ceremonies/persistBASHThe start answers a run document at once (state: "running"); the snapshot itself takes as long as the store is big. Read the run with GET on the same path. While it runs the receipt is the phase it is in — flush, lock-wait, phase-1, linking, sealing — with the generation it is cut at once the commit lock is held; when it is done the receipt is { generation, name } (plus rewrittenDuringCopy / retiredDuringCopy, store-relative paths, only when the live store changed a rebuildable cache or stamp — the census, the clean-close marker, a family stamp — while the snapshot copied: the snapshot then holds the newer version, or omits it, and the next open rebuilds it). The answer never contains a path: you know the folder, the brain and the name.
From code, await client.persist("2026-09-29-nightly") starts the run and polls it, and resolves with { generation, name } — the same call shape as in process, except that the argument is a name.
One snapshot runs per brain at a time; a second start is refused CeremonyInProgressError (HTTP 409) carrying the running run.
What the host refuses
Refusal | When |
|---|---|
| the name is not one path segment (1 to 255 bytes; no |
| the host has no |
| the root does not exist or is not a folder, overlaps the brains directory, or the brain's folder inside it is a symbolic link or resolves elsewhere |
| the name already holds files |
| a snapshot of this brain is already running |
| not the service key |
Every refusal is answered before anything is written. The host never overwrites a snapshot and never deletes one: a folder left by a run that was interrupted is yours to remove, and the next run uses a fresh name — use a new name per night. A refusal's text never contains the root's absolute path; the host's own log has the whole text.
If the host restarts while a snapshot runs, the run is gone: status answers none, and client.persist throws CeremonyRunLostError. Remove the partial folder and start again under a new name.
What is in a snapshot
The same layout as a live brain's directory, without its locks/: the generation history, the entity and blob trees, the derived index families and the canonical pointer and manifest. The few files that are appended to in place (the active canonical segment, the id-mapper and graph delta-log tails, the facts tail, the transaction log) are byte-copied under the commit lock, up to the cut; everything else is hard-linked after the lock is released. When the store is sealable a cross-projection seal is written last. A snapshot that could not be sealed is still complete; it is only that Brainy.load() refuses it.
Disk
At the moment of the cut a snapshot on the brains' filesystem costs the byte-copied files above plus directory entries for the links.
Over time a hard-linked file the live store later merges or rewrites stays allocated in the snapshot, so a snapshot's real cost grows with how much the live store changes after the cut. Delete old snapshots you no longer need.
On another filesystem every file is a full copy: the size of the store. This is not measured here; size for the whole store.
Restore
Restoring is a copy, then a normal open.
Choose a brain name that is not in the brains directory and copy the snapshot folder to it, sparse-preserving:
cp --sparse=always -a <root>/<brain>/<name> <brains-dir>/<new-brain>. Copy; never move, link or symlink it — hard-linked files share their blocks with the live store, and the folder is your only backup.Never open the snapshot folder itself as a brain. A writer open changes it.
The host lists its brains from the directory on every call, so the copy is served on the next request. The first open is a writer open of a store with no
locks/and no clean-close record.Check it:
health, thencountsand a spot read against what the source held at the snapshot'sgeneration. A row written after the snapshot was cut is not in it.To replace a live brain, stop serving it, move its directory aside, and put the copy under the same name. In process,
brain.restore(path, { confirm: true })does a staged copy and an atomic swap; it is not a served door.
The restore is pinned by a test that takes a snapshot through a host, copies it into a fresh brains directory, opens it and compares the counts and every row (src/client/persistCeremony.e2e.test.ts). What is not measured: the first open of a restored store at production size.