State backup
rt backs up the databases and topology files that would be painful to lose, encrypts them with age, and pushes them to your home repo so they survive a disk failure or a new machine.
What is protected
Four sources are backed up every cycle:
| Source | Contains |
|---|---|
rt/state.db | Worktree registry, branch/MR caches, daemon tracking, process inventory |
rt/gates.db | Multi-step gate progress (setup wizard state, pending decisions) |
board/state.db | Board agent states, triage memory, MR review verdicts |
gitq/stacks/ | Branch parent-chain topology (archived as a tar) |
Everything else (the events bus, background claims, herd state) is small and re-derivable, so it is not included.
Setting it up
rt state backup init
Init finds age, zstd, and git-lfs (see Dependencies) and stops if any is missing. The home repo must already exist, so run rt home init first if you have not. Then init:
- Installs Git LFS in the home repo (
~/.mattstack/user/), points its LFS filters at the resolvedgit-lfs, and adds a.gitattributesrule so.agefiles are tracked by LFS. - Creates
recipients.txtwith this machine's age public key, read from the macOS Keychain. The private key stays in the Keychain and is never written to disk. - Runs the first full backup immediately.
- Verifies the backup can be decrypted (a round-trip sanity check).
After init, the daemon's 4-hour sweep handles everything automatically.
The backup cycle
Every 4 hours (with a 60-second delay after daemon start), the daemon:
- Snapshots each source (
VACUUM INTOfor SQLite databases,tar -cfor gitq stacks) into a temp directory. - Compresses with
zstd -19. - Encrypts with
age -R recipients.txt. - Writes the final
.ageblob into~/.mattstack/user/state-backups/<app>/, plus amanifest-<timestamp>.jsonbeside the app folders. - Prunes old backups (7-day retention, keeping the newest file per source prefix so a stable source always has at least one copy).
The home repo's snapshot engine auto-commits and pushes within about 80 seconds, so the encrypted blobs land on the remote shortly after each cycle.
Content-hash deduplication skips a source whose SHA-256 matches the previous cycle and whose .age file still exists on disk, so a quiet database does not produce duplicate blobs.
You can also trigger a backup manually:
rt state backup # run the full pipeline now
rt state backup --local # local-only VACUUM INTO of rt/state.db, no encryption or push
rt state backup status # per-app latest backup, count and size, plus push and LFS state
Restoring
Same machine
rt state restore --from-backup
This pulls the home repo (fetching LFS blobs), finds the most recent backup for each source, decrypts with the Keychain key, decompresses, runs SQLite's quick_check on each restored database, and places the files. The daemon must be stopped first (the command refuses while it is running or its socket exists, unless you pass --force).
New machine
On a machine that does not have the original Keychain key, pass an age identity file (typically the team key):
rt state restore --from-backup --identity ~/team-key.txt
With --identity, that file is the key. Without it, the command reads your personal Keychain key, and says so plainly when there is none.
Selective restore
rt state restore --from-backup --only rt # rt/state.db and rt/gates.db
rt state restore --from-backup --at 2026-09-10 # point-in-time
rt state restore --from-backup --dry-run # preview without writing
When something goes wrong
- Integrity check failure: that source is not overwritten; it is reported as an error and the command exits 1. Pick an older backup with
--at. - A source failed during backup: the daemon logs a
backup errorswarning, keeps that source's previous backup in the manifest, and retries on the next sweep. The failure shows up in the daemon log (rt daemon logs). - Backup not set up (no
recipients.txt): the sweep takes a local-onlyVACUUM INTOcopy ofrt/state.dbunder~/.mattstack/rt/backups/instead (unencrypted, not pushed). It does the same when every source fails in a cycle.
The age key
The private key lives in the macOS Keychain, never on the filesystem. recipients.txt holds public keys only. rt state backup init writes just this machine's personal key; a team public key, for the second recovery path, has to be added to the file by hand. With both present, either key can decrypt independently: the Keychain on the original machine, or the team identity file on a new one.
Dependencies
age, zstd, and git-lfs ship inside mattstack.app (Contents/Helpers/). rt uses the bundled copy first and falls back to PATH when there is none (for example, rt running from source). The daemon's sweep resolves them the same way. Because the home repo's LFS filters name an absolute git-lfs path, every init, sweep, and restore rewrites them from the current resolution, which keeps them working after the app moves.