Skip to main content

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:

SourceContains
rt/state.dbWorktree registry, branch/MR caches, daemon tracking, process inventory
rt/gates.dbMulti-step gate progress (setup wizard state, pending decisions)
board/state.dbBoard 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​

bash
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:

  1. Installs Git LFS in the home repo (~/.mattstack/user/), points its LFS filters at the resolved git-lfs, and adds a .gitattributes rule so .age files are tracked by LFS.
  2. Creates recipients.txt with this machine's age public key, read from the macOS Keychain. The private key stays in the Keychain and is never written to disk.
  3. Runs the first full backup immediately.
  4. 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:

  1. Snapshots each source (VACUUM INTO for SQLite databases, tar -c for gitq stacks) into a temp directory.
  2. Compresses with zstd -19.
  3. Encrypts with age -R recipients.txt.
  4. Writes the final .age blob into ~/.mattstack/user/state-backups/<app>/, plus a manifest-<timestamp>.json beside the app folders.
  5. 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:

bash
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​

bash
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):

bash
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​

bash
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 errors warning, 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-only VACUUM INTO copy of rt/state.db under ~/.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.