The backup bundle format
The backup bundle format
For someone restoring a resmon backup by hand, or writing a tool that reads one. The user-facing account is the Backup and Restore section of the README; this is the file layout and the manifest.
A bundle is an ordinary directory. Nothing is compressed and nothing is encrypted, so every part of it can be read with the tools already on the machine — which is the point: a backup you cannot open without the application that wrote it is a hostage, not a backup.
Layout
resmon-backup-<UTC stamp>/ e.g. resmon-backup-20260922T134501Z
├── resmon.db the database, snapshotted
├── vault/ present only when the database has a Library vault
│ ├── vault.json {"version": 1, "vault_id": "<uuid>"}
│ └── files/<file_id>/<version_id>.<ext>
├── reports/ present only if the reports checkbox was on
└── manifest.json
resmon.db is written with sqlite3.Connection.backup from the live connection, not
copied. resmon runs SQLite in WAL mode and never checkpoints, so a file copy of
resmon.db alone is missing whatever is still in resmon.db-wal. The backup API reads
through the WAL and produces one self-contained file; there are deliberately no
-wal/-shm files in a bundle, and a bundle that had them would be suspect.
vault/ mirrors the vault root exactly, relative path for relative path, including the
vault.json marker. Every retained file is re-hashed while it is copied and compared
with the sha256 in library_files; a mismatch aborts the whole backup. The vault
directory’s own name is not carried — it is resmon-library-<vault_id> by construction,
and the restore rebuilds it from library_vault.root_path in the restored database.
manifest.json
{
"manifest_version": 1, // this document describes version 1
"app_version": "2.2.0",
"schema_version": 21, // database schema; a restore into an OLDER app is refused
"created_at_utc": "2026-09-22T13:45:01.123456+00:00",
"elapsed_seconds": 4.117,
"vault_id": "1f8a…" , // null when the database has no vault
"includes_reports": true,
"table_counts": { "documents": 18243, "executions": 61, … },
"table_count_denominator": 34, // how many application tables were counted
"fk_violations": [ // orphan rows PRAGMA foreign_key_check found, capped at 100
{ "table": "library_file_documents", "rowid": 4, "parent": "documents", "fkid": 0 }
],
"fk_violations_total": 1, // the uncapped count of unsatisfied references
"fk_violations_rows": 1, // how many distinct rows those references come from;
// absent on a bundle written before this field existed
"files": [ // every file in the bundle except manifest.json itself
{ "path": "resmon.db", "size": 214958080, "sha256": "…" },
{ "path": "vault/vault.json", "size": 61, "sha256": "…" },
…
],
"excluded": {
"credentials": ["smtp_password", "openai_api_key", "webhook_secret_3"],
"process_state": ["daemon.lock", "resmon.port", "api-token-<port>"],
"state_dir": "/Users/…/Library/Application Support/resmon",
"note": "…"
}
}
files[].path is relative to the bundle root and is what a verifier re-hashes.
manifest.json is not in its own list; the pointer a staged restore writes records the
manifest’s own SHA-256 separately, so a manifest edited after staging is caught.
fk_violations is measured on the bundle’s own database, not on the live one. A corpus can
carry a row whose parent is missing — foreign_key_check is a check, not a constraint on rows
that already exist — and resmon opens such a database quite happily. The backup records what it
finds and still writes the bundle: refusing here is what once left a user with a backup that
verified and could never be restored. Verify reports the rows, and the restore is staged only
when the request carries accept_fk_violations: true. The pragma answers per constraint, not
per row, so one row with two unsatisfied foreign keys appears twice — fk_violations_total
is therefore a count of unsatisfied references, not of rows, and resmon’s own wording says
“references” for that reason. fk_violations_rows is the same references de-duplicated by
(table, rowid), so a manifest can say “2 references, from 1 row” rather than leaving a
reader to divide by an unknown.
Two caveats, both load-bearing:
fk_violations_rowsis absent on a bundle written before it existed.manifest_versionstays1, because the field is additive and everything that reads a manifest treats its absence as not measured — never as zero. A verify report of such a bundle carriesfk_violations_rows: nulland its sentence reads exactly as it did before.PRAGMA foreign_key_checkanswersrowidNULL for aWITHOUT ROWIDtable, and two such references cannot be told apart — they may be one row or two. They are counted infk_violations_totaland deliberately not infk_violations_rows, so the row count is a count of the rows SQLite identified and never a guess. Where every reference is of that kind,fk_violations_rowsis0with a non-zero total and resmon says it cannot count the rows rather than printing “from 0 rows”. resmon’s own schema has noWITHOUT ROWIDtable today, so this is a guard rather than a case.
excluded.credentials holds names, never values. No credential value is written into
a bundle under any circumstance. The names are there so that a restore can tell the user
which keyring entries they must re-enter on the machine they restored onto. Webhook
signing secrets are named webhook_secret_<row id> after a routine_delivery_targets
row, so a target that comes back under a different id has no secret bound to it.
Restoring by hand
- Verify first. Recompute the SHA-256 of every file in
manifest.filesand compare. - Stop resmon. The database must not be open by any process.
- Move your current
resmon.db,resmon.db-walandresmon.db-shmsomewhere safe. Do not delete them until the restore has proved itself. - Copy the bundle’s
resmon.dbinto place. -
If the bundle has a
vault/, readlibrary_vault.root_pathout of the restored database and copyvault/there, asresmon-library-<vault_id>, mode0700. Remove any.import.lock: it has no automatic stale-lock recovery, and one left behind makes the vault permanently busy.That path names the machine the backup came from, and on another machine it often does not exist. Put the directory under any parent you like — the name must stay
resmon-library-<vault_id>, whichlibrary.vault_rowchecks — and then rewrite the row to match:UPDATE library_vault SET root_path='<parent>/resmon-library-<vault_id>' WHERE singleton=1;. Do it after step 6, so the statement runs against today’s schema. In the application this is the Restore the vault somewhere else… control on the verify card; the chosen parent travels in the staged pointer asvault_parent, and the restore rewrites the row itself, inside the same failure envelope as everything else.If a vault directory is already there, move it aside rather than deleting it — that is what the application does, into
restore-undo/<stamp>/vault-replaced/beside the database files of the same restore, so one Delete undo copies removes both halves and a restore that fails after this point puts both back. Where the destination held no vault at all, the application removes the tree it wrote instead, so a failed restore leaves the folder you chose exactly as empty as it found it. A vault on a different volume from the state directory is copied rather than renamed, which costs a second copy of every retained byte; that is the right trade against deleting the only copy there is.There is one case the undo cannot complete by itself: a move between volumes interrupted while it was removing the original leaves the original incomplete and a whole copy in
restore-undo/<stamp>/vault-replaced/. Putting that copy back automatically would be wrong — the other interruption, during the copy, leaves a partial copy beside an intact original — so resmon keeps it instead of deleting it with the rest of the undo directory, names it inrestore-last.jsonasvault_copy_kept, and leaves it for you to put back by hand. A failed restore can therefore leave an undo copy that Settings → Storage offers to delete; checkrestore-last.jsonbefore you delete one. - Start resmon once, or otherwise run the migrations, before the statements below. An older
bundle does not have the tables and columns they name — a v2.2.0-era database has no
deliveriestable at all — and running them first is how resmon itself got this wrong. -
Reset the rows that describe a process that no longer exists.
awaiting_reviewdeliveries are deliberately left alone: they describe a decision the user has not made, not a process.UPDATE executions SET status='interrupted', interrupted_reason='unknown', owner_pid=NULL, owner_runtime_id=NULL WHERE status='running'; UPDATE executions SET owner_pid=NULL, owner_runtime_id=NULL; UPDATE deliveries SET state='queued', owner_pid=NULL, owner_runtime_id=NULL WHERE state='delivering'; UPDATE deliveries SET owner_pid=NULL, owner_runtime_id=NULL; INSERT INTO documents_fts(documents_fts) VALUES ('rebuild');The full-text index is derived and is not rebuilt for you by opening the database.
- Check
PRAGMA integrity_check, andPRAGMA foreign_key_checkagainstmanifest.fk_violations— rows already listed there were orphaned before the backup and are not caused by the restore. - Re-enter every credential
manifest.excluded.credentialsnames.
The application does all of this for you, in this order, on the start after you stage a restore in Settings → Storage. Doing it by hand is for the case where you no longer have an application to ask.