- Python 79.8%
- HTML 11.1%
- JavaScript 6%
- CSS 2.8%
- Shell 0.2%
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> |
||
|---|---|---|
| .forgejo/ISSUE_TEMPLATE | ||
| app/latch | ||
| docs | ||
| installer | ||
| tests | ||
| tools | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| requirements.txt | ||
| run-dev.sh | ||
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.mdbefore 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_TOKEN — left 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/readybadges from each module'sdashboard_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,/tasksor/wheel—tests/test_settings_split.pyis 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'sPOST /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 adatetime-local(when, local toLATCH_TZ); API:atepoch seconds.core/when.pyparses both;service.*guards them (see Traps). An event recorded more than 2 minutes after its time carriesbackdated: true+recorded_atand 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) andPOST /api/v1/media/photo. Event kindphoto(+5 xp), media kindcheckin, 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 withModule.progress_panelsand observations withModule.insights, both asked-for-never-registered likebadge_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 typedspecs; 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 throughcore.service/core.events.emit. Theeventstable is append-only and is the log, the timeline, the stats and the audit trail at once. Freeze = event pair; while frozenremainingis held. - Undo never deletes.
POST /session/undoappends anundoevent and stampsvoided_aton 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>/exposingmodule(acore.registry.Module): own routes, templates, seed data,on_tick/on_eventhooks 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 inmedia/thumbs/(a memo has none: audio has no pixels).GET /media/{id}/stampedcomposites 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|shortcutis 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 fabricated5for every question nobody answered — on a form whose own help text promised the opposite. The input carries nonameuntil 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)onbadgesenforced nothing for every row wheresubject_id IS NULL— i.e. every badge the pup wears — soINSERT OR IGNOREnever ignored and each sweep re-awarded the lot. Present, correct in shape, silently inert for half the table. Fixed with a unique index overCOALESCE(subject_id, -1). ⚠️ An idempotency test whose fixture only produces non-NULL subjects passes this vacuously — ours did. -
🔴 The HTML
hiddenattribute loses to anydisplayrule. A.row{display:flex}element withhiddenset renders in full, and nothing errors: the DOM says hidden, the eye says visible.[hidden]{display:none!important}is now instyle.css; it is used in five places across the app and was backed by nothing. -
🔴 In Jinja,
b.itemsresolvesdict.items— the METHOD — before the key. A dict carrying anitemskey fails with "builtin_function_or_method is not iterable", which names neither the key nor the collision.kit_usesrows therefore carrythings, notitems. -
🔴
timer.compute(session, events, now)ignores events afternow.nowis the instant being asked about, not merely the horizon for an open freeze — that is what letsservice.freeze/unfreezeat 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 passingnow + 60to 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.lockrefuses a start that overlaps the previous session;adjust/freeze/unfreezerefuse a time before the active lock started (require_active_at);unfreezerefuses where the state at that time was not frozen (which is also "before its freeze");tasks.complete/failrefuse a time beforeassigned_at. Notes, photos and Handler entries attach tosession_at(now)— the lock that was on THEN — and a completion inside a closed session applies its reward to nothing. -
🔴 The
backdatedstamp is applied inevents.emit(), by comparingtsto the wall clock at insert. Sweeps that stamp at a real time (task deadline, freeze expiry) therefore read asbackdatedafter downtime — correct, they were recorded late.undo_candidatemeasures its window fromrecorded_at, so a just-typed backdated entry is still undoable. -
🔴
Module.settings_keysis the ONLY way a key becomes writable throughPOST /settings. The save route intersects the form with the union of core + module keys and drops the rest./handler/settingsand/wheel/settingsstill exist for compatibility but the page doesn't use them. -
🔴
LATCH_API_TOKENempty ⇒ 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, nevercp. 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 andbundle.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_srcis the presence flag, NOT the coordinate. A genuine fix in the Gulf of Guinea rounds to(0.00, 0.00), soif place_lat:silently drops a real location; a typed label carriessrc='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.
placeis absent from every DEFAULT_*_TOKENS list. Amilestonecard 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.TONEmixes decoration (ink/muted/dim/line/accent/accent2) with meaning (ok/bad/warn/ice/cyan), anddelta_colorputs 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_METALis a hand copy of the badge forge'sRARITY— forced, becausetools/is not in the image (.dockerignoreis an allowlist).tests/test_theme.pyreads the real file off disk and fails on drift. -
🔴 Badge rarity comes from the RULE's
xp, never frombadges.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/dataor/tmp. -
⚠️
run-dev.shand the container share nothing:.data/on the Studio is throwaway. -
🔴
service.session_events()selects an EXPLICIT column list and is the only feed intotimer.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_atis in that list for exactly this reason. -
🔴 Voiding an
unfreezewould inflate the clock by every second since itsfreeze. The reducer opensfrozen_sinceon 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_REFUSEblocks it. Voiding a freeze is safe (its orphaned unfreeze hitselif ... and frozen_since is not None→ False → inert). Never "simplify"UNDO_REFUSEby noticing that the freeze direction works — the asymmetry is the whole point. -
🔴
undo_candidateorders byid DESC, notts DESC. The reducer sorts by(ts, id)because that is chronology, but "undo the last thing" means the last thing recorded.emit()accepts an explicittsand modules use it (tasks.expire_overduestamps atask_failedat 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 intask_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 ingame.py, and the handler's "did you reflect today" join all filtervoided_at IS NULL. That uniformity is why undo voids rather than emitting a compensating delta — a compensating −600 would fixadjust_secondswhile inflating bothtime_addedandtime_removedand leavingspins/tasks_verifiedcounting 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.mediaDevicesis 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 falsy0skipped it entirely — and both routes declareduration_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. Ahandler.voice_min_secondsof 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 (~12–384 kbps); outside that band it is stored NULL. A NULL duration means 'unknown', never 'zero' —
totals.voice_secondsunder-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 andmedia.store_audio()is audio-only, deliberately two functions sharing a private_persist(). Do NOT merge them behind akindargument: 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
entriesrow of kind='reflection' withmedia_idset. 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_allowlistis the standing guard, and it is a positive allowlist: a new/api/v1route 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/eventsalready broke, and it iteratedapp.routes, which on this FastAPI holds_IncludedRouterwrappers carrying nopathat all. -
🔴
/api/v1/eventsreturns a WHITELISTED payload (export.API_PAYLOAD_KEYS), not the stored row.handler._record()copiesbody[:140]intopayload['excerpt']and the five check-in scores intopayload['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,promptare absent because they are not listed. It is built ON TOP ofSHARE_PAYLOAD_KEYSso the two cannot drift. -
🔴
/api/v1/handler/entriesreturns reflection and check-in BODIES, by design — that is arguably its purpose, and it is the single reasonLATCH_API_TOKENis 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,/redocor/openapi.json. FastAPI's Swagger page<script>s swagger-ui fromcdn.jsdelivr.netand<link>s a favicon fromfastapi.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=Nonematters as much: the schema is the full route inventory and the bearer dependency does not guard it.test_no_page_references_an_off_box_originis the standing guard for the front end. -
🔴 An upload is capped on the STREAM, never on the result.
await file.read()thenif len(data) > MAXreturns 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 tomem_limit: 512m. Two layers:main.MAX_BODY_BYTESrefuses on Content-Length before a byte is read (protects tmpfs; blind to chunked bodies), andcore/uploads.py::read_cappedbounds 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 + frozenandstarted_atis printed on the dashboard, so printingbaseon a live hidden lock hands back the end time by arithmetic. Five places leaked it and each needed its own fix: thebase_secondstoken (FIRST in the default recap and dossier lists),_timer.html's facts row,_event.html'slockrow (which printed the sentence and then announced "hidden timer"), the emergency-keyconfirm()(which printedremainingverbatim), and the session table (which renders live sessions too; it is on/progress?tab=logsince 0.8, and the leak test iterates every tab because a surface behind a tab is still a surface). ⚠️adjust/frozendeliberately stay visible: breaking one operand is what makesend_atunrecoverable, 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.noteunless asked (?note=1).export.pyclassifies that field as unshareable in two separate places whilerecap.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_SQLis the separate, stricter PIL gate — counting and decoding are different questions. -
🔴
/export/bundle.zipbuilds 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 ayielddependency 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_loopwrites 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 readsid.
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.