🐾 Handlr ?

Guide β€Ί 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.

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.egress in 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.
  • Some modules declare egress (/settings lists every one). 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 .ics file 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/v1 for 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. Kennel Card builds your page without opening any connection; the one call it makes is to read another pup's card, from the link you paste, on that tap or when you tap Refresh β€” its data, its signature and its photos, never its page. On a hosted instance that link must be on the public internet. With no card read, the footer leaves it out; once one is kept, it says reaches out: kennelcard.
  • 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. Handlr answers only to the names it has been given (HANDLR_ALLOWED_HOSTS, or the one you typed on Settings β†’ System), plus IP addresses and localhost, 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, or X-Forwarded-Host). A proxy that rewrites Host to 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 Handlr itself (so a same-origin form carries its real Origin), 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 /docs loads 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_origin scans every served template and static file for an http(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. Handlr 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/events returns 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/today says what is due, never what was written.
  • /api/v1/state omits the session note.
  • /api/v1/handler/entries is 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/v1 route 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.py seeds 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. Handlr 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 is docs/privacy.md in the repo. Spotted a mistake? Tell us how to report it β†’