Latch

Data

Export and backup

Eleven export routes, what each is for, the CSV conventions, pup.json, how to back up and restore an instance with latch.restore, and the one warning that matters.

On this page

Your data is one SQLite file and one media folder. Export gives you readable copies of both; backup is your job, and it is a small one.

The full export is an unencrypted copy of every reflection and check-in

It is the copy you restore from. It is never the copy you share. Share stamped photos and recap cards โ€” those are built by whitelist. The export page and the bundle's MANIFEST.txt both say this in one line, on purpose.

The routes

All under /export, all behind whatever authentication your reverse proxy provides, none on the API. Filenames are latch-<what>-YYYYMMDD-HHMM.<ext> in your local time.

Route What it is
/export the page, with size estimates
/export/latch.json every table, verbatim โ€” the only authoritative copy, the one to restore from
/export/daily.csv one row per local day, gaps filled with zeros โ€” the one to open in a spreadsheet
/export/events.csv one row per event; undone rows are kept and marked with voided_at
/export/sessions.csv one row per lock, clock recomputed from the log
/export/entries.csv reflections and check-ins, the five scores as their own columns
/export/assignments.csv every training assignment
/export/kit.csv one row per thing you own: specs, cost, bouts, hours, cost per bout
/export/bouts.csv every bout of play or wear โ€” what was on, how long, caged or not, how it ended
/export/bundle.zip all of the above plus media/ (originals and thumbnails), your installed playsets' files (playsets/), your sealed Pup ID key (identity/pup.key.enc, when you set a passphrase) and MANIFEST.txt, streamed. ?media=0 for data only (the playset files and the sealed key still ride)
/export/pup.json your Pup ID, for arriving on your own new box โ€” see below

The bundle carries your key only sealed, under your passphrase, and says in MANIFEST.txt whether it did. The plain key (pup.key) and this box's own key (instance.key) are never in any export.

A window of time: chapters and slices

Every route above except kit.csv and pup.json also takes a window, and then exports a slice โ€” only what happened inside it:

The Log does this for you: open ๐Ÿ—“ Range, pick this lock, an event, this month, last month, everything or two dates, and the export links under it carry the window. Save as a chapter keeps the window under a name โ€” a preset saves its exact window (a lock to the second), a range saves whole days.

What a slice holds: events, reflections, assignments, photos, bouts, trance sits, habit logs, badges and Handler lines whose time is inside the window; health days inside it; every lock that overlaps it, whole (daily.csv clips them per day, and keeps every zero day inside the window); the chapters overlapping it with their notes; and the catalogues (activities, prompts, tasks, kit) whole, so the ids resolve. Filenames carry the window's name: latch-slice-locktober-2026-YYYYMMDD-HHMM.zip.

A slice is not a backup

Its rows point at rows outside the window, so latch.restore refuses it โ€” the JSON says "kind": "slice" and the zip's MANIFEST.txt says so in its title. It carries no sealed key and no playset files. And it is unencrypted, with every reflection in the window in full.

Reading a chapter. On the Chapters page, Read it opens the chapter as a story: a cover (the chapter's chosen photo, or its first), the foreword, what it added up to โ€” hours locked, the longest stretch, reflections, tasks, photos, badges, the most-locked day and the best headspace โ€” the hours and check-in trends across it (by week once it is longer than three months), and then every day in order: one line of what happened, its moments (locks, unlocks, tasks, badges), the reflections and check-ins written that day in full, its photos, and the looking-back notes about it. Runs of empty days fold into quiet for N days. A finished chapter is told as of its last day, so it reads the same whenever you open it; an open one grows until it ends.

Writing in a chapter. The story page is also where you add the second voice โ€” today's words about then. Write a foreword at the top; choose the cover photo from the photos taken inside the chapter (or leave it on the first); and under any day, add a note about this day โ€” about the whole day, or about one thing on it: a reflection, a photo, a lock or an unlock. A note says when you wrote it, and when you last revised it. A day that was folded as quiet can have a note too, from add a note about any day at the foot of the page. Notes never change the Log, and they are private prose: no shareable export carries them.

A goal. A chapter can carry one want, in your own words โ€” four days of guitar a week โ€” and, if you like, how the log should measure it: a weekly habit held to its own target (every full week, or a number of weeks on target), days locked, or days with a reflection. Set it on the story page (๐ŸŽฏ). While the chapter runs, the Handler opens its line with it every day and the dashboard shows it; the story โ€” and the keepsake โ€” say whether it was reached, and only once that is known: a count of days is reached the moment you hit it and not reached only when the chapter has closed short; a habit goal of "every week" on a chapter with an end is lost the moment one full week falls short; an open-ended chapter's is never decided, only read as k of w weeks on target. A goal with no measure reads said, not measured. It rides in pup.json and in the full export, and never in a shareable copy.

Keepsakes

A keepsake is a chapter as one file you keep: on its story page, open ๐Ÿ” Make a keepsake, set a passphrase, and Latch hands you a single .html file. Inside it is the whole story โ€” cover, foreword, the summary and trends, every day with its reflections, photos and your looking-back notes โ€” and the chapter's data (the same slice as latch.json?chapter=), with a button to save it out.

Save the passphrase now

Put it in a password manager (Bitwarden, 1Password, the Passwords app) or Apple Notes when you make the file. A keepsake whose passphrase is lost cannot be opened โ€” by you, by the person who wrote Latch, by anyone. There is no recovery and no back door, which is the point.

Photos are re-encoded (1600 px, or 1080 px for a smaller file, or left out) with their EXIF and location stripped; the originals stay in your box. A keepsake is a copy to keep, not a backup โ€” restore only from the full export.

A shareable copy

Beside it, ๐Ÿ“ค Make a shareable copy makes a second, different kind of file โ€” one meant to be shown to someone. It is plain and unencrypted, and it is built from a short list of what may leave, never by trimming the private keepsake:

Chapters are yours, so they travel. They ride in the full backup like every other table, and in pup.json: a note about one event arrives on the new box as a note about that event's day, because the event itself stays behind. A pup.json made before chapters still arrives; one made on a box with chapters needs a box at least as new to arrive on.

pup.json

Your pup without the game: the sealed key, your profile, every paper, the toys and gear and the photos you marked ๐Ÿงณ (photos re-encoded โ€” no location, no camera details, no caption). No log, no reflections. It needs a passphrase set on the Pup ID page; without one the route sends you there. See Identity.

pup.json holds your sealed key

Never hand it to anyone โ€” not a Handler, not a pack. Whoever has it and your passphrase is your pup. It is for arriving on your own box.

pup.json carries no pack. Each den keeps your membership through the box you made it on, and refuses a join by your Pup ID from the new box until it has let go of the old one โ€” so leave your packs there first (Packs), then join again from the new box with fresh tags. This page and the Pup ID page name every pack a pup.json would leave behind, with a link to leave each; the download still works (a warning, not a block). bundle.zip keeps your packs.

CSV conventions

Backing up an instance

The container writes to one volume (/data). A backup is that volume, taken WAL-safely:

# the database โ€” never `cp` a live SQLite file; the WAL may hold hours of writes
docker exec latch python -c "import sqlite3; s=sqlite3.connect('/data/latch.db'); d=sqlite3.connect('/tmp/latch.backup.db'); s.backup(d); d.close()"
docker cp latch:/tmp/latch.backup.db ./latch-$(date +%F).db
# the media โ€” plain files, copy them however you like
docker cp latch:/data/media ./media-$(date +%F)

Or simply download /export/bundle.zip on a schedule you will actually keep. Verify a backup by a content watermark, never by file size:

SELECT COUNT(*), MAX(ts) FROM events;

The same two numbers on the live database and the copy mean the copy is current.

Restoring a bundle

The app restores its own bundle:

python -m latch.restore --data-dir /data --bundle latch-bundle-20260920-1024.zip --passphrase-file pass.txt

It refuses a box that is not fresh (no locks, no events, no badges, no Pup ID) and a bundle made by a newer Latch. Before it writes anything it also refuses a bundle whose files or photo rows point outside the data folder, a sealed key that is not the Pup ID the bundle names, a passphrase that does not open it โ€” so a typo costs nothing: run it again โ€” and a box whose clock is behind the bundle's newest record (set the clock, then run it again: a resume dated after the restore's pause would otherwise lift that pause by itself once the clock caught up). Then it inserts every table in order with foreign keys off, checks every table's row count against MANIFEST.txt and runs PRAGMA foreign_key_check (a mismatch writes nothing and exits 2), and commits the rows together with the pause below; then it copies the photos, the playset files and the sealed key back. With the passphrase it unlocks your Pup ID; without it the Pup ID waits, locked, for the Pup ID page. Leave out --passphrase-file and it asks.

In the container (the setup wizard's install, or any Docker one), run it from the install folder: copy the zip into the data volume, restore inside the container, then restart it.

docker cp latch-bundle-20260920-1024.zip latch:/data/
docker compose exec latch python -m latch.restore --data-dir /data --bundle /data/latch-bundle-20260920-1024.zip
docker compose restart

A box you have opened but not played on is still fresh, so the running app does not stand in the way. The zip can be deleted from /data afterwards (docker compose exec latch rm /data/latch-bundle-20260920-1024.zip).

Exit codes: 0 restored ยท 1 refused, nothing written ยท 2 the check failed (nothing written), or copying the files failed after the rows were committed โ€” it then says so ("restored, but: โ€ฆ"), and the box is already paused.

A restore pauses your packs and your AI

If you granted anybody anything, the restored box comes back paused โ€” nobody else can see or do anything until you have looked at Consent and resumed. A restored box is also never "packing to move": it is the box you moved to โ€” though it keeps the record of when the old box was packing, so a command a Handler sent to it meanwhile never lands here.

Only the photo files the restored rows name are copied back; anything else under media/ in a bundle is skipped and counted. A bundle made on a box that never set a passphrase carries no sealed key, and the game comes back with no Pup ID: make a new one on the Pup ID page, or arrive yours there from a pup.json. The papers the old key signed stay as history, under Signed by an earlier Pup ID; the new one papers every badge afresh.

Moving to a new box

A move is explicit, and it happens on the Pup ID page (Identity has every step):

  1. Prepare to move. Packs, your AI and papers stop; your own game โ€” the lock, the emergency key โ€” keeps working. Cancel any time before you retire.
  2. Take your pup: the whole game as bundle.zip, restored on the new box with python -m latch.restore (above), or just the pup as pup.json, arrived on the new box's Pup ID page.
  3. Retire this box once the pup is alive on the new one. It asks for the passphrase that opens the sealed copy โ€” the proof that the copy you are relying on works โ€” then deletes this box's plain key and keeps the sealed one. It refuses while no sealed copy exists.
  4. Bring my pup back undoes a retire, with the same passphrase.

Restoring by hand

  1. Start a fresh instance (empty volume) so the schema exists.
  2. Stop it. Replace /data/latch.db with your backup (or import latch.json table by table with foreign keys off โ€” events.voided_by points forward to a later id, so an id-ordered insert with them on fails partway; the JSON lists tables in a safe order).
  3. Copy media/ and playsets/ back under /data/. Thumbnails are optional: a missing one is rebuilt from the original on demand. Without playsets/ your playsets come back with their badge art missing.
  4. Start it. /healthz reports the version and the session state; /progress should read as it did.

Run PRAGMA foreign_key_check after a JSON import. A media row whose bytes are gone shows in the gallery as a refusal, not a crash.

Where the data is

Container Dev on a workstation
database /data/latch.db (WAL mode) ./.data/latch.db
media /data/media/YYYY/MM/ + media/thumbs/ ./.data/media/
playset files /data/playsets/<slug>/ (art, scores, README) ./.data/playsets/
your Pup ID /data/identity/ โ€” pup.key (0600; plain unless seal-at-rest), pup.key.enc (sealed), instance.key ./.data/identity/
the name given on Settings /data/allowed_hosts.pin (only if you did not set LATCH_ALLOWED_HOSTS) ./.data/allowed_hosts.pin
settings rows in the settings table same

Nothing is stored anywhere else.

Originals keep their EXIF

The full backup is a backup: photos go in exactly as you uploaded them, with whatever your camera wrote into them (GPS, the camera's serial). Share cards, stamped photos, recaps and the photos in pup.json are re-encoded and carry none of it.

Your volume backups hold your key

A backup of the whole /data volume includes identity/pup.key in plain form unless seal-at-rest is on (see Identity).


This page also ships inside the app, at /guide โ€” so your own instance always serves the guide for the version you are running, with the internet unplugged. Get Latch ยท Something wrong here? Tell me.