Data
Privacy and what never leaves
The exact boundaries โ no egress, no accounts, prose versus facts, what a share card can and cannot carry โ and the tests that keep them true.
On this page
Privacy is the first principle here, and the rest of the design follows from it. This page is the list of what that means in practice, so you can check it rather than take it on faith.
Nothing leaves the box
- The core makes zero outbound calls. The app itself contacts nothing. No update check, no analytics, no crash reporter, no font, no script, no icon from a CDN. This guide is rendered on your instance from markdown in the repository.
- A module may reach the network โ only if it declares it. The core is sealed; optional
modules are where anything outbound can live. Such a module declares its egress in one
sentence โ
Module.egressin the code, rendered on Settings above that module's own knobs and named in the System line โ and does nothing until you configure it. This is honesty, not a wall: the app can say a module says it reaches the network โ it cannot promise one never could, because nothing stops a socket being opened. A test reads every file's imports (tests/test_egress_declarations.py): it fails when the core imports anything that opens a socket, when a module does without declaring it, or declares egress it no longer has โ and when a module that can put itself in the footer declares nothing. Class-P data never rides along either way โ health, location, journal and the rest stay on the box โ with one exception you choose: your own AI Handler, on this machine or your own network, once you switch it on. It gets your journal only in its chat and only under Read reflections โ chat only (up to seven reflections), your kit's names only under Your toys / Your gear, and one yes/no from your health data โ "go gently today". Nothing health-derived beyond that bit. - Today three modules declare egress. Habits fetches the calendar
feed URLs you add, on the schedule you set, sends nothing but the request, stores no response
bodies, and fetches nothing at all until you add a URL. A fresh install makes zero calls.
Importing an
.icsfile instead makes none ever. On a hosted instance a feed must be on the public internet โ private and local addresses are refused at every redirect โ so nobody can use the server to look around the host's own network. The AI Handler calls the Ollama server at the address you give it โ only on this machine or your own network, over plain http, re-checked on every call; never a cloud model, never on a hosted instance โ and calls nothing until it has an address and is switched on. The footer then says reaches out: ai. It sees facts and only your own words; your reflections reach it in its chat only, under a grant. Packs talk to the den of a pack you joined โ only at the address in the collar tag you accepted โ sending only the cards your grants allow (photos and kit only the ones you ticked, re-encoded without metadata), sealed and signed; a box that hosts a pack answers/den/v1for its members. With no pack joined and none hosted it makes no call and accepts none; once one is joined or hosted, the footer says reaches out: pack. Nothing health-derived and no prose is ever in a card โ and the den's operator can read every card it relays. - The footer says which modules are reaching out right now. It reads local only until a module is configured to reach the network (a calendar URL added under Habits, say), and then reaches out: habits. It is computed from what is configured, never a fixed sentence.
- The chat bot is named there too. It is not a module โ it is a separate program you run, a client of the API โ but while a bot token is live what it reads leaves for a chat room, so the footer says reaches out: bot. What it may read is a short, fixed list (facts, today's reflection prompt, a line your AI said that names no kit, no training title and no hidden time), decided on the box, not by the bot โ see Consent โ the chat bot and the chat bot, which stops the moment its room is public, world readable, encrypted or bigger than you allow.
- A stranger's web page cannot read your box. Latch answers only to the names it has been
given (
LATCH_ALLOWED_HOSTS, or the one you typed on Settings โ System), plus IP addresses andlocalhost, so a page that points its own name at your box (DNS rebinding) gets nothing โ not the dashboard and not/export/bundle.zip. A name is never learnt from a request, so no page can make its own name yours. A form sent from another site is refused too. โ ๏ธ One condition: behind a reverse proxy, the proxy must pass the name you used (Host, orX-Forwarded-Host). A proxy that rewritesHostto its upstream address (nginx's default) hides the name, and if it also answers other names a rebinding page on one of them can read the box through it. See the names it answers to and behind a reverse proxy. - No referrer leaves the box. Pages send
Referrer-Policy: same-origin: a referrer goes only back to Latch itself (so a same-origin form carries its realOrigin), never to a link you follow. - No accounts. There is no sign-up, no server of ours, no "us". One instance, one player.
- No Swagger page. FastAPI's
/docsloads its UI from a CDN and a favicon from a project site โ a beacon on every visit. It is disabled, along with/openapi.json(which would publish the route inventory to anyone without a token). The API is documented here instead. - Enforced by a test.
tests/test_flow.py::test_no_page_references_an_off_box_originscans every served template and static file for anhttp(s)://host and fails on anything that is not a namespace identifier or an error-message string. Add a CDN font and the suite goes red.
Sharing is explicit egress
The only things that ever leave are files you exported and chose to send:
- Stamped photos, recap cards and share cards are re-encoded JPEGs. Their EXIF is gone because they are rendered from pixels โ the original metadata is never copied across, so camera model, serial and original GPS cannot ride along.
- Location is the one exception, and it is deliberate. If you ask for it, a card can
print a coarse place โ by default a ~1 km cell. That coordinate was rounded when the
photo was saved, so the exact position is not in the database, and nobody who gets a
card can sharpen it. โ ๏ธ The original photo file is kept exactly as you uploaded it. If
your camera wrote GPS or its serial number into the file, that stays in the file โ in the
gallery's full-size view, in your backups and in the export bundle. Only the re-encoded
artefacts (stamps, share and recap cards) are clean. Recording it is a setting
(
photo.place_capture); printing it is off unless that specific export asks. See Location on photos โ including what rounding does not buy you. pup.jsonโ your Pup ID, for arriving on your own new box โ carries only photos you marked ๐งณ, re-encoded the same way (no EXIF, no colour profile, no caption, no place), and kit without its text fields. It also carries your key, sealed: never hand it to anyone. See Identity.- The phone's share sheet is the browser's Web Share API handing your file to another app. Latch itself posts nothing there.
- Your Handler's profile is the one thing that posts, and only once you set it up: your AI Handler, on an account of its own, writing from exactly the categories you grant (what you've earned, what you got wrong, photos you tick, your own words), each post a draft you approve unless you hand that over too. Never anybody else, never a hidden lock's time, never your health. Off, with nothing granted, until you choose otherwise.
Prose versus facts
Every design decision about what can be seen where comes down to one line: facts are shareable; prose is yours.
| Examples | Share cards | /api/v1 event feed |
Export | |
|---|---|---|---|---|
| Facts | how long, day N, streak, tasks done, rank, which photo, which task | yes | yes | yes |
| Prose | reflection text and excerpts, check-in scores and notes, session notes, adjustment reasons, photo captions | never | never | yes |
Concretely:
/api/v1/eventsreturns a whitelisted payload per event (ids, flags, durations, catalogue labels). The stored payload carries a reflection excerpt and check-in scores; the API does not./api/v1/handler/todaysays what is due, never what was written./api/v1/stateomits the session note./api/v1/handler/entriesis the one deliberate exception: it returns reflection and check-in bodies, because a local bot that reads your journal is a designed use. That single route is why the API token is a LAN-only secret โ it belongs in a password manager and never in anything that leaves the house.- Export lives outside the API entirely, behind the reverse proxy's authentication. The
bearer token โ designed to sit in a phone Shortcut โ cannot pull your whole journal in one
request. A test asserts every
/api/v1route against a written allowlist; a new route fails the suite until someone writes down what it exposes. - Recap cards do not print the session note unless the URL says
?note=1. tests/test_api_privacy.pyseeds marker strings into prose fields and greps the raw bytes of every API response for them.
The hidden sentence
With a hidden timer, started + sentence = end. The start time is on the dashboard, so the
sentence is the secret: it is withheld from the timer, the log rows, the stats table, the
overlay tokens and the emergency-key confirmation until the lock ends. Adjustments and frozen
time stay visible โ breaking one operand is enough.
Your Pup ID's key
The key that is your pup lives in identity/pup.key under your data folder (0600). It leaves the
box only sealed under your passphrase โ inside pup.json and the backup zip โ and never in
plain form. Your volume backups and snapshots contain it in plain form unless seal-at-rest is
on; on a hosted instance the host holds it either way โ seal-at-rest keeps it off the host's
disk and backups, but the host runs the app that holds it and receives your passphrase. There is
no escrow and no recovery
service. See Identity โ Key custody.
The container
read_only: true, no-new-privileges, a 512 MB memory limit, an unprivileged user, a tmpfs
/tmp. Uploads are capped on the stream (before bytes are read), not on the result, so an
oversize POST costs a refusal and not memory. There is no shell tool in the image that could
reach out.
What you are responsible for
- Put it behind authentication. Latch has no login of its own. A reverse proxy with an auth layer, or a private network, is required โ see Install.
- Do not expose it to the internet. Nothing in it is designed for that.
- Encrypt your backups at rest if the disk they land on is not already encrypted. The export is plaintext by design.
- Keep the API token private. It can read your journal.
- Screenshots carry what is on screen. Before you send one to report a bug, look at it.
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.