A solo lock game for pups — Handler, wheel, photo-verified training, and an honest play log. Self-hosted, AGPL-3.0. https://latch.aurionblack.com
  • Python 79.8%
  • HTML 11.1%
  • JavaScript 6%
  • CSS 2.8%
  • Shell 0.2%
Find a file
Lockedtrainee 00195c9de6 docs: the public mirror exists — contributing.md names it
forge.aurionblack.com/lockedtrainee/latch. Browse, clone, file an issue, open a PR.

Two things the page has to say out loud, because both are surprising:

- Sign-up is open but every account is activated BY HAND, so there is a wait between
  registering and being able to post. Silence there looks like rejection otherwise.
- The mirror is DOWNSTREAM and force-pushed on every upstream push. Issues, pull
  requests and comments are native to it and safe; only the git refs are overwritten.
  A commit pushed straight to the mirror's `main` would vanish on the next sync, so
  changes come as pull requests.

pack.py's ALLOW gains forge.aurionblack.com — the guard bans `.aurionblack.com` so that
an estate hostname cannot ride along, and the project's own front doors are the listed
exceptions. Adding a doc that names the mirror without this would fail the build, which
is the guard working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:46:17 -04:00
.forgejo/ISSUE_TEMPLATE 0.3.0: the Guide — user docs served by the app at /guide, federation (packs) proposal, issue templates 2026-09-14 07:30:01 -04:00
app/latch release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
docs docs: the public mirror exists — contributing.md names it 2026-09-20 15:46:17 -04:00
installer installer: reproducible .app/.exe build scripts (#7) + frozen-app data paths 2026-09-20 12:39:37 -04:00
tests release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
tools docs: the public mirror exists — contributing.md names it 2026-09-20 15:46:17 -04:00
.dockerignore 0.3.0: the Guide — user docs served by the app at /guide, federation (packs) proposal, issue templates 2026-09-14 07:30:01 -04:00
.env.example release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
.gitignore release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
CHANGELOG.md progress: four tabs, inline-SVG charts and the Insight block (0.8.0) 2026-09-20 10:02:45 -04:00
docker-compose.yml release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
Dockerfile The trance module, the photo picker, and four privacy fixes 2026-09-19 07:01:01 -04:00
LICENSE decisions: AGPL-3.0 licence, rank rename (stray→runt), core-vs-module egress 2026-09-20 12:11:37 -04:00
README.md release: make the source publishable — estate fingerprints out, a guard that fails closed 2026-09-20 15:36:07 -04:00
requirements.txt 0.3.0: the Guide — user docs served by the app at /guide, federation (packs) proposal, issue templates 2026-09-14 07:30:01 -04:00
run-dev.sh latch: pup-headspace lock game + play log (P0) on apps pod, guarded, at latch.example.lan 2026-09-12 23:47:59 -04:00

Latch

A solo lock game with a Handler, a wheel, photo-verified training and an honest play log.

Single-player, self-hosted, and built in the register of pup headspace. You lock, the clock runs, and the game — through its Handler, its wheel and its training — shapes what happens to that clock and to your head while it does. Everything becomes an event in an append-only log that is your timeline, your stats, your streaks and your stamped photos.

Website and screenshots: https://latch.aurionblack.com · Full manual: docs/, and served by your own instance at /guide.

18+. Latch is an adult game about consensual chastity and pup headspace. It never talks to a physical device and you always hold your own key. The in-app emergency key ends a session at any time and is never hidden, throttled or shamed. Read docs/spirit.md before your first lock.

Free software under AGPL-3.0-or-later, © Lockedtrainee.

Quick start

docker compose up -d --build     # → http://localhost:8770

Play data lives in the latch_data Docker volume, not the working directory, so you can rebuild or reinstall without losing anything. First run only, if you want the JSON API: cp .env.example .env and set LATCH_API_TOKENleft empty, the API stays off.

Prefer to click through it? ./setup.sh (macOS/Linux) or setup.bat (Windows) opens a local setup wizard that checks Docker, writes your config and starts it for you. See installer/README.md.

Development

uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -r requirements.txt pytest httpx
./run-dev.sh                     # http://127.0.0.1:8770 — data in ./.data, API token "dev-token"
.venv/bin/python -m pytest -q    # 🔴 `python -m`, not `.venv/bin/pytest`: test_activities imports
                                 # tests.test_stamp, and only `python -m` puts the repo root on sys.path

tools/demo/seed_demo.py fills a throwaway database with ~150 days of plausible play, for screenshots and for working on the charts:

LATCH_DATA_DIR=/tmp/latch-demo python3 tools/demo/seed_demo.py

Releasing

python3 tools/release/pack.py    # → dist/latch-<version>.tar.gz

🔴 It fails closed on an estate fingerprint — a hostname, a private address, a deployment path or the maintainer's name anywhere in the tracked tree aborts the build rather than shipping it. Estate-side deployment notes live in the aurora/homelab monorepo, never here.

Data volume latch_latch_data/data (latch.db WAL + media/)
API /api/v1/*, bearer LATCH_API_TOKEN (unset = API disabled). 🔴 No /docs, /redoc or /openapi.json (the user guide is /guide, unrelated) — FastAPI's Swagger page pulls swagger-ui from cdn.jsdelivr.net and a favicon from fastapi.tiangolo.com, i.e. a per-visit outbound beacon on a Class-P app. The route inventory is app/latch/api/routes.py and the allowlist in tests/test_export.py.
Guide /guide — the user documentation, rendered by the app from docs/*.md (Python-Markdown, no CDN, client-side search). Same files render on Forgejo. docs/README.md says how to write a page.
Version 0.8.0 — see CHANGELOG.md
route what
/export the page
/export/latch.json every table, verbatim — the only authoritative copy, and the one you restore from
/export/daily.csv one row per local day, gaps filled with zeros. The one to open in Numbers
/export/events.csv one row per event; undone rows are kept and marked with voided_at
/export/sessions.csv one row per lock, timer recomputed by core.timer.compute
/export/entries.csv reflections + check-ins, the five scores flattened into their own columns
/export/assignments.csv every training assignment
/export/kit.csv one row per thing owned: specs, cost, bouts, hours, cost per bout. Empty = unmeasured, never 0
/export/bouts.csv every bout of play or wear — items, duration, locked, outcome, survey answers
/export/bundle.zip all of the above + media/ (originals and media/thumbs/) + MANIFEST.txt, streamed. ?media=0 for data only

The surfaces (0.2)

  • Dashboard = the play surface. Timer → an actions row (📸 photo check-in · 🐾 Handler · 🎡 wheel · 📋 training, with due / N open / ready badges from each module's dashboard_card) → Clock (quick ±, freeze, fine adjust in a fold) and Session (note, undo, unlock) → module cards → recent. The lock form's extras (dice, hidden timer, note, started earlier) are in a fold. No editor and no rule form renders on /, /handler, /tasks or /wheeltests/test_settings_split.py is the standing guard.
  • /settings = everything else. Core groups (Clock & undo · Stamps & recap), then one section per module: its rule form (Module.settings_keys, saved by the core's POST /settings) plus its editors (Module.settings_panel() → prompt library / training library / wheel segments), then System. Every play page and module card carries a ⚙ link to its section.
  • The retroactive log. Every write takes an optional when — web forms: a collapsed ⏱ not now? fold with a datetime-local (when, local to LATCH_TZ); API: at epoch seconds. core/when.py parses both; service.* guards them (see Traps). An event recorded more than 2 minutes after its time carries backdated: true + recorded_at and shows ↶ logged later in the log. The lock form's Locked since is how a game starts mid-stream.
  • Photo check-in. POST /checkin/photo (one tap from the dashboard: the hidden file input submits on pick) and POST /api/v1/media/photo. Event kind photo (+5 xp), media kind checkin, attaches to the lock that was on at that time. Counts as a photo everywhere photos are counted; stamped export works on it.
  • /progress = the quantified side (0.8, was /stats, which is still a 307 alias). Four tabs — Overview (tiles, per-week rates, the 26-week heatmap, activity strips) · Trends (inline-SVG charts, no library on the box) · Insight (rule-based observations with their numbers under them) · Log (training board, event kinds, sessions). Each tab is its own request computing only itself. Modules add charts with Module.progress_panels and observations with Module.insights, both asked-for-never-registered like badge_rules.
  • Share. a[data-share] links (recap card, stamped photo) hand the JPEG to the phone's share sheet via the Web Share API; no API → plain download. Secure origin only, like the recorder.
  • /kit = the inventory (0.6). Toys measured in bouts, gear in time worn, consumables in what's left; per-category typed specs; photos per item; a bout is one row holding several things; the cage's wear bout is opened and closed by the lock itself. The end-of-bout form is the outcome + three optional sliders. /badges = the cabinet, core not kit, showing the whole catalogue and not only what has been won.

How it works

  • The timer is derived, never stored. end_at = started_at + base + Σ delta_seconds + Σ frozen. Every module mutates the game only by emitting an event through core.service / core.events.emit. The events table is append-only and is the log, the timeline, the stats and the audit trail at once. Freeze = event pair; while frozen remaining is held.
  • Undo never deletes. POST /session/undo appends an undo event and stamps voided_at on its target; timer.compute() then skips that event wholesale (continue), which is what makes one guard cover deltas, freezes and unfreezes alike. The voided row stays in the log, struck through in the UI — that visual is the append-only guarantee made legible. Only the newest event of the active session is ever a candidate, and it is named on the button. Window-bounded (default 15 min, core.undo_window_minutes, 0 = no limit).
  • Modules are packages under app/latch/modules/<name>/ exposing module (a core.registry.Module): own routes, templates, seed data, on_tick/on_event hooks and a dashboard card. Discovery is by directory listing — nothing to register.
  • Scheduler: an in-process loop every LATCH_TICK_SECONDS (30) expires timed freezes and fails overdue tasks (actor=system).
  • Photos and voice memos are content-addressed under /data/media/YYYY/MM/ — one store, two doors — with photo thumbnails in media/thumbs/ (a memo has none: audio has no pixels). GET /media/{id}/stamped composites the overlay (state recomputed at the photo's timestamp from the log) and re-encodes → EXIF/GPS is stripped by construction. A missing thumbnail is rebuilt from the original on demand, and both ride in the bundle.
  • API actors: X-Latch-Actor: user|api|ai|shortcut is recorded on every write.

Traps

  • 🔴 An <input type="range"> ALWAYS submits its current value. An untouched slider posts the midpoint, so the obvious end-of-bout survey recorded a fabricated 5 for every question nobody answered — on a form whose own help text promised the opposite. The input carries no name until it is moved (kit_end.html); with JS off it is never named, so the failure mode is losing an answer rather than inventing one. Every survey-shaped form in this app has to do this.

  • 🔴 SQLite treats NULLs as DISTINCT in a UNIQUE. UNIQUE (slug, subject_type, subject_id, period) on badges enforced nothing for every row where subject_id IS NULL — i.e. every badge the pup wears — so INSERT OR IGNORE never ignored and each sweep re-awarded the lot. Present, correct in shape, silently inert for half the table. Fixed with a unique index over COALESCE(subject_id, -1). ⚠️ An idempotency test whose fixture only produces non-NULL subjects passes this vacuously — ours did.

  • 🔴 The HTML hidden attribute loses to any display rule. A .row{display:flex} element with hidden set renders in full, and nothing errors: the DOM says hidden, the eye says visible. [hidden]{display:none!important} is now in style.css; it is used in five places across the app and was backed by nothing.

  • 🔴 In Jinja, b.items resolves dict.items — the METHOD — before the key. A dict carrying an items key fails with "builtin_function_or_method is not iterable", which names neither the key nor the collision. kit_uses rows therefore carry things, not items.

  • 🔴 timer.compute(session, events, now) ignores events after now. now is the instant being asked about, not merely the horizon for an open freeze — that is what lets service.freeze/unfreeze at a past time ask "was the clock frozen then?". It also means an event stamped slightly in the future (the 120 s grace) is invisible until its time comes, and a test that simulates the future by passing now + 60 to a sweeper must read state at that same instant (test_overdue_task_fails_with_penalty).

  • 🔴 Backdating guards live in core/service.py, not in the routes. lock refuses a start that overlaps the previous session; adjust/freeze/unfreeze refuse a time before the active lock started (require_active_at); unfreeze refuses where the state at that time was not frozen (which is also "before its freeze"); tasks.complete/fail refuse a time before assigned_at. Notes, photos and Handler entries attach to session_at(now) — the lock that was on THEN — and a completion inside a closed session applies its reward to nothing.

  • 🔴 The backdated stamp is applied in events.emit(), by comparing ts to the wall clock at insert. Sweeps that stamp at a real time (task deadline, freeze expiry) therefore read as backdated after downtime — correct, they were recorded late. undo_candidate measures its window from recorded_at, so a just-typed backdated entry is still undoable.

  • 🔴 Module.settings_keys is the ONLY way a key becomes writable through POST /settings. The save route intersects the form with the union of core + module keys and drops the rest. /handler/settings and /wheel/settings still exist for compatibility but the page doesn't use them.

  • 🔴 LATCH_API_TOKEN empty ⇒ API 503 on purpose. The web UI still works (it sits behind tinyauth). Don't "fix" the 503 by inventing a default in compose.

  • 🔴 sqlite is WAL — back up with Connection.backup()/sqlite3 .backup, never cp. Watermark: SELECT COUNT(*), MAX(ts) FROM events.

  • ⚠️ iPhone HEIC uploads are rejected (Pillow has no HEIF decoder here) with a clear 409. Shortcuts → Convert Image → JPEG, or set the camera to "Most Compatible". P5 wires the Shortcut.

  • 🔴 Coarse location is rounded AT INGEST (core/geo.py), never at render. The exact coordinate is never written to disk, so it is absent from the db, the backups and bundle.zip — and a shared card cannot be sharpened back. Rounding at render would look identical on every card and still ship the doorway inside the export.

  • 🔴 media.place_src is the presence flag, NOT the coordinate. A genuine fix in the Gulf of Guinea rounds to (0.00, 0.00), so if place_lat: silently drops a real location; a typed label carries src='manual' with no coordinate at all. (0,0) straight off a GPS chip with no fix is refused before rounding — absent is never a place.

  • 🔴 EXIF first; the browser fix only for a LIVE upload. The browser says where the phone is now, which for a backdated photo is a different claim from where it was taken.

  • 🔴 Display precision can only coarsen (geo.grid_dp + min()). Trailing zeros are a precision claim: a 1 km cell printed at 3 dp asserts 110 m accuracy it does not have.

  • 🔴 The share card holds TWO instants — stats as-of the card's own moment, photograph as-of its shutter — and keeps them visibly apart (card time in the eyebrow, photo time burned inside the picture so it survives a crop). Blending them is the P0 overlay's lie in a new place, so the photo caption is not optional. place is absent from every DEFAULT_*_TOKENS list. A milestone card refuses a photo outright: its numbers are already as-of the badge, so a picture would make it three moments.

  • 🔴 A card theme may not recolour a SEMANTIC tone. stamp.TONE mixes decoration (ink/muted/dim/line/accent/accent2) with meaning (ok/bad/warn/ice/cyan), and delta_color puts ADDED time in RED. A gold theme that swept everything gold would erase the sign of every adjustment and still look beautiful. theme.tones() themes the first set only.

  • 🔴 theme.BADGE_METAL is a hand copy of the badge forge's RARITY — forced, because tools/ is not in the image (.dockerignore is an allowlist). tests/test_theme.py reads the real file off disk and fails on drift.

  • 🔴 Badge rarity comes from the RULE's xp, never from badges.tier — that column is 0 for every badge in the app (no rule sets one), so a mark keyed on it would draw every badge the same grey while looking exactly like a working rarity system.

  • 🔴 A badge's emoji icon is never drawn — every emoji is .notdef in the bundled face. The card draws a hexagon in the badge's metal instead.

  • ⚠️ Wheel cooldown defaults to 60 min (Settings / wheel page). Set 0 while testing.

  • ⚠️ Photo-required tasks refuse to complete without a photo — that's the point.

  • ⚠️ The container is read_only — anything that must write goes in /data or /tmp.

  • ⚠️ run-dev.sh and the container share nothing: .data/ on the Studio is throwaway.

  • 🔴 service.session_events() selects an EXPLICIT column list and is the only feed into timer.compute(). A reducer guard keyed on a column missing from that SELECT is inert and fails silently — the UI strikes the row through while the clock never moves. voided_at is in that list for exactly this reason.

  • 🔴 Voiding an unfreeze would inflate the clock by every second since its freeze. The reducer opens frozen_since on a freeze and closes it on an unfreeze; skip the unfreeze and the interval runs to the horizon, so a three-day-old freeze hands back three days. service.UNDO_REFUSE blocks it. Voiding a freeze is safe (its orphaned unfreeze hits elif ... and frozen_since is not None → False → inert). Never "simplify" UNDO_REFUSE by noticing that the freeze direction works — the asymmetry is the whole point.

  • 🔴 undo_candidate orders by id DESC, not ts DESC. The reducer sorts by (ts, id) because that is chronology, but "undo the last thing" means the last thing recorded. emit() accepts an explicit ts and modules use it (tasks.expire_overdue stamps a task_failed at the deadline it swept), so a system event can post-date the user's actual last action and shadow it. Insertion order is immune to that and to backdating.

  • ⚠️ Undo is deliberately not offered for task_verified/task_failed/task_assigned — those carry side effects in task_assignments; reopen the assignment on the Training card instead. A spin whose effect was a freeze or an assignment is refused for the same reason.

  • ⚠️ A voided event stops counting everywhere, not just on the clock: every aggregate in stats.py, the xp sum in game.py, and the handler's "did you reflect today" join all filter voided_at IS NULL. That uniformity is why undo voids rather than emitting a compensating delta — a compensating 600 would fix adjust_seconds while inflating both time_added and time_removed and leaving spins/tasks_verified counting play that didn't happen.

  • ⚠️ Stamped overlays read the log contemporaneously: an event voided after a photo was taken still appears in that photo's overlay. Otherwise every undo would silently rewrite the caption of every photo already in the gallery.

  • 🔴 The recorder needs a SECURE ORIGIN. It works over https:// (or on http://localhost) and is structurally absent on a plain http:// origin — navigator.mediaDevices is undefined on an insecure origin. That is a UI message, not a bug; the fix is to reach Latch over TLS through your reverse proxy.

  • 🔴 A spoken reflection with no duration is REFUSED, not waved through. The gate read if duration_seconds and duration_seconds < need, so a falsy 0 skipped it entirely — and both routes declare duration_seconds: int = Form(0), so simply omitting the field bought full reflection XP and suppressed the next morning's missed-reflection penalty for a recording nobody measured. Ogg is measured first (granulepos), then the client's claim, then refusal. A handler.voice_min_seconds of 0 means there is no minimum and an unmeasured memo is accepted.

  • 🔴 There is no ffmpeg in this image and there will not be. Voice duration is CLIENT-REPORTED, clamped to 600 s and cross-checked against the implied byte rate (~12384 kbps); outside that band it is stored NULL. A NULL duration means 'unknown', never 'zero' — totals.voice_seconds under-reports by exactly those memos, on purpose. Ogg/Opus is the one exception: its last-page granulepos is an exact sample count and is used instead of the claim. The column cannot say which of the two it holds.

  • ⚠️ media.store() is images-only and media.store_audio() is audio-only, deliberately two functions sharing a private _persist(). Do NOT merge them behind a kind argument: a kind-aware branch fails OPEN — one mistyped kind string at a call site walks an arbitrary blob past PIL.

  • ⚠️ A spoken reflection is an entries row of kind='reflection' with media_id set. Only the MEDIA kind is 'voice'. Filing the entry as 'voice' would make the next morning's audit charge the missed-reflection penalty for a reflection he did.

  • 🔴 Export is on the CORE router (tinyauth), NOT on /api/v1. The bearer token is designed to live in an iOS Shortcut and in Hermes; it must never be able to pull every reflection in one GET. Do not "unify" them — tests/test_export.py::test_api_v1_surface_is_an_explicit_allowlist is the standing guard, and it is a positive allowlist: a new /api/v1 route fails the suite until someone writes down what it exposes. ⚠️ The denylist it replaced ("export" not in path) was vacuous twice over — it certified an invariant /api/v1/events already broke, and it iterated app.routes, which on this FastAPI holds _IncludedRouter wrappers carrying no path at all.

  • 🔴 /api/v1/events returns a WHITELISTED payload (export.API_PAYLOAD_KEYS), not the stored row. handler._record() copies body[:140] into payload['excerpt'] and the five check-in scores into payload['scores'], so a passthrough handed every reflection to a token that lives in a Shortcut and in an LLM gateway. The line is prose vs fact: ids, flags, durations and catalogue labels are in; excerpt, scores, note, text, reason, prompt are absent because they are not listed. It is built ON TOP of SHARE_PAYLOAD_KEYS so the two cannot drift.

  • 🔴 /api/v1/handler/entries returns reflection and check-in BODIES, by design — that is arguably its purpose, and it is the single reason LATCH_API_TOKEN is a LAN-only secret that belongs in a password manager and must never be pasted anywhere that leaves the house. It is named explicitly in the allowlist; delete that line and delete the route with it.

  • 🔴 No /docs, /redoc or /openapi.json. FastAPI's Swagger page <script>s swagger-ui from cdn.jsdelivr.net and <link>s a favicon from fastapi.tiangolo.com — a per-visit outbound beacon, from a link that used to sit in the footer of every page, on an app whose own /settings reads "Local only. No outbound calls." openapi_url=None matters as much: the schema is the full route inventory and the bearer dependency does not guard it. test_no_page_references_an_off_box_origin is the standing guard for the front end.

  • 🔴 An upload is capped on the STREAM, never on the result. await file.read() then if len(data) > MAX returns the correct 409 and still pays for the attack: measured, a 150 MB POST answered 409 while RSS went 53 MB → 213 MB, because starlette spools any part over 1 MB into /tmp — which is tmpfs, i.e. RAM billed to mem_limit: 512m. Two layers: main.MAX_BODY_BYTES refuses on Content-Length before a byte is read (protects tmpfs; blind to chunked bodies), and core/uploads.py::read_capped bounds what the handler copies (needs no header). All four upload routes go through the one helper.

  • 🔴 The SENTENCE is the secret. end_at = started_at + base + adjust + frozen and started_at is printed on the dashboard, so printing base on a live hidden lock hands back the end time by arithmetic. Five places leaked it and each needed its own fix: the base_seconds token (FIRST in the default recap and dossier lists), _timer.html's facts row, _event.html's lock row (which printed the sentence and then announced "hidden timer"), the emergency-key confirm() (which printed remaining verbatim), and the session table (which renders live sessions too; it is on /progress?tab=log since 0.8, and the leak test iterates every tab because a surface behind a tab is still a surface). ⚠️ adjust/frozen deliberately stay visible: breaking one operand is what makes end_at unrecoverable, and both of those are announced the moment they happen. Once the lock has ENDED the sentence is history and is shown again.

  • 🔴 The recap card does not print sessions.note unless asked (?note=1). export.py classifies that field as unshareable in two separate places while recap.py's own docstring calls the card "a share artefact" — private prose on a public object has to be opt-in.

  • ⚠️ kind <> 'voice' is the ONE predicate for "a photograph" (media.PHOTO_SQL). Four counters each had their own answer and a recap card printed "4 PHOTOS" for a session holding one photograph and three voice memos. media.DECODABLE_SQL is the separate, stricter PIL gate — counting and decoding are different questions.

  • 🔴 /export/bundle.zip builds every in-memory member inside the endpoint and streams file bytes only; export.zip_stream() touches the DB zero times. ⚠️ Measured 2026-09-13: the usual "FastAPI ≥0.106 closes a yield dependency before the body streams" does NOT reproduce on the installed fastapi 0.141.1 / starlette 1.6.0 — a real uvicorn server happily served a query from inside a StreamingResponse generator. The split stays anyway, because the failure mode is silent (a raise after the headers = a truncated zip that looks complete) and because holding the connection pins a WAL read snapshot while _tick_loop writes every 30 s. Do not "simplify" it back on the strength of today's version.

  • ⚠️ Exported CSVs carry a UTF-8 BOM on purpose (Excel otherwise reads UTF-8 as the system codepage). Python readers need encoding='utf-8-sig' or the first header column reads id.

Export

Nine routes on the core router, all behind tinyauth. Filenames are latch-<what>-YYYYMMDD-HHMM.<ext> (local time, pure ASCII by construction).

route what
/export the page
/export/latch.json every table, verbatim — the only authoritative copy, and the one you restore from
/export/daily.csv one row per local day, gaps filled with zeros. The one to open in Numbers
/export/events.csv one row per event; undone rows are kept and marked with voided_at
/export/sessions.csv one row per lock, timer recomputed by core.timer.compute
/export/entries.csv reflections + check-ins, the five scores flattened into their own columns
/export/assignments.csv every training assignment
/export/kit.csv one row per thing owned: specs, cost, bouts, hours, cost per bout. Empty = unmeasured, never 0
/export/bouts.csv every bout of play or wear — items, duration, locked, outcome, survey answers
/export/bundle.zip all of the above + media/ (originals and media/thumbs/) + MANIFEST.txt, streamed. ?media=0 for data only

🔴 The full export is an unencrypted copy of every reflection and check-in, and it is never the shareable form. MANIFEST.txt and the /export page both say so in one line. The P6 shareable class is a separate endpoint built by whitelist (export.doc(..., redact=True)), never a checkbox on this one.

⚠️ Each timestamp appears twice in every CSV: the epoch column is the stored truth, the *_local column (YYYY-MM-DD HH:MM:SS, no offset, zone in the tz column) is the one a spreadsheet parses as a date. An empty score cell means no measurement was taken — it does not mean zero, the scale starts at 1.

🔴 Thumbnails ship in the bundle, and a missing one is rebuilt rather than served as a 500. They used to be omitted on the stated grounds that core.media.store regenerates them — it runs on UPLOAD and never again, so nothing regenerated a thumbnail for an existing row and a restore left every gallery tile at HTTP 500 (verified by renaming one thumb: /media/1/thumb → traceback, /gallery → 200). Both halves are fixed: the archive carries them, and media.ensure_thumb() rebuilds one from the original on demand. /media/{id} likewise refuses with a 409 instead of a 500 when the bytes are gone.

⚠️ Restore with foreign keys off, then PRAGMA foreign_key_check: events.voided_by points forward to a later event id, so an id-ordered insert with foreign_keys=ON fails partway through events. The JSON lists tables in a foreign-key-safe order.