# Find My Device, on Officer **Status: DESIGN, NOT BUILT.** Sketched 2026-08-19, the same day push notifications went in — which is not a coincidence, because push is the piece that makes this possible at all. ## What this is, and what it will never be **The pitch is not "replace Find My". It is "know where your devices are without Apple or Google being the ones who know."** Location history on your own server, on your own tailnet, under your own retention rule. That part is mostly assembly. **The part that is genuinely impossible: offline finding.** Apple's magic is not the map. It is that a lost iPhone with no network still broadcasts an encrypted identity over Bluetooth, and roughly a billion strangers' devices relay it home. That is a crowd, and you do not have one. Nor does any self-hosted system, ever. So the honest boundary: | | | | --------------------- | ------------------------------------------------------------------ | | Device has network | **works** — current position, on demand or on a schedule | | Device is off-grid | **last known position and nothing more** | | Device is powered off | **nothing.** Apple's is silicon-level; there is no software answer | | Device is wiped | **nothing** | A tracker that answers "where was it last, and when" covers the overwhelmingly common case — left in a taxi, lost in the house, stolen and still online. It does not cover the case Find My is famous for. Anyone building this should be clear about that before starting, because the gap is not a to-do item. ## What already exists More than expected, which is why this is worth doing now rather than someday. - **The wake channel.** You cannot reach a sleeping phone except through APNs/FCM. That is built, `officer/core:*` codes and all, and a real device is registered. See `docs/push-notifications.md`. - **A map.** `plugins/photos/web/MapSection.tsx` — maplibre-gl with markers, clustering, bounds-fitting, already working and already pinned to a version that behaves. Read its header comments before writing a second map; they record two traps that cost someone a day. - **Device presence, free.** offscale/headscale already reports `online` and `lastSeen` per node (`plugins/offscale/sidecar/normalize.ts`). "Is my laptop awake, and when was it last seen" needs no new code at all — and it covers laptops and desktops, which the phone-app approach never will. - **The tailnet**, so a device reports home without anything being exposed publicly. - **The hub app**, already installed, already registered, already receiving pushes. What is missing is a table, an endpoint, some app code and a panel. ## Architecture Two paths, and they are not the same shape. ``` REPORT (device → server, most of the time) phone ──POST /api/find-my/report──→ platform ──→ device_locations COMMAND (server → device, rarely) panel ──→ officer-notify ──push──→ hub app ──→ acts, then REPORTs ``` **Everything of value flows on the report path.** The command path exists only for "where is it _right now_", and it is the unreliable one — so the design must not depend on it. ### The report path The device posts a fix. That is the whole thing. It works when the app is foregrounded, when iOS hands it a background location update, and when a queued batch drains after a period offline. **Batching matters.** A phone that has been off-grid for six hours has six hours of fixes. The endpoint takes an array, and every fix carries its OWN timestamp from the device clock — which is why the schema below separates `recorded_at` from `received_at`. Collapsing them loses the distinction between "you were there at 14:00" and "the phone told me at 20:00", and that distinction is the entire value of a history. Device clocks are wrong sometimes. Store both, trust neither blindly, and let the UI show `recorded_at` while any "is this stale" logic uses the pair. ### The command path, and why it is the weak one To make a sleeping phone report _now_, you push. The options, in order of preference: 1. **Silent push** (`content-available: 1`), app wakes, takes a fix, posts it. The right shape — and **Apple throttles these and does not guarantee delivery.** Already established in `docs/push-notifications.md`, and it is the reason silent pushes were rejected for notifications. 2. **A visible push the user taps.** Reliable, but requires a human at the other end, which defeats the purpose when the phone is in a taxi. 3. **A Notification Service Extension taking the fix on delivery.** Attractive because the NSE runs on a normal, reliable, high-priority push. **Unverified** — location access from an extension is possible in principle but unusual, and nobody here has tried it. Worth an afternoon before committing. **Design conclusion: treat on-demand as a bonus, never as the mechanism.** If the background reporting is good, "where is it now" is answered by "where was it 4 minutes ago", and that is enough for almost every real situation. ## Device identity — do NOT key this on the push token The most likely serious mistake in this design, so it goes above the schema. `push_devices` is unique on `(token, bundle_id)`, and **APNs tokens rotate**. A location history keyed to a rotating token silently forks into two devices, and the split looks like the user bought a new phone. The registration path is already an upsert precisely because tokens churn. So a device needs a **stable identity generated once on the device at first launch** and persisted across token rotations, app updates and re-registrations — a UUID in the keychain, not derived from anything Apple gives you (`identifierForVendor` resets when all your apps are uninstalled). That means a `devices` table that is NOT `push_devices`: - `push_devices` answers "where do I send a push" — churny, plural per device, token-keyed. - `devices` answers "which physical thing is this" — stable, one row per device, and what location, battery and presence hang off. A `push_devices.device_id` foreign key joins them. This also makes a device that has _never_ registered for push (a laptop reporting over the tailnet) representable, which the push table alone cannot do. ## Schema sketch ``` devices id serial user_id fk users device_uuid text -- generated on-device, stable forever. unique with user_id name text -- "André's iPhone", user-editable platform text -- ios | android | macos | linux model text -- reported, cosmetic created_at, last_seen_at device_locations id bigserial device_id fk devices latitude double precision longitude double precision accuracy_m real -- ALWAYS store it; a 3km fix rendered as a pin is a lie altitude_m real nullable speed_mps real nullable heading_deg real nullable battery_pct smallint nullable is_charging boolean nullable source text -- significant | ondemand | visit | manual | geofence recorded_at timestamptz -- the DEVICE clock, when the fix was taken received_at timestamptz -- the SERVER clock, when it arrived ``` Index on `(device_id, recorded_at desc)` — every query is "latest for this device" or "this device between two times", and both want that order. **`accuracy_m` is not optional.** iOS returns fixes with 3km accuracy when it is being cheap about it, and a UI that draws those as a confident pin will send someone to the wrong street. Draw the circle. **Battery belongs here**, not in a separate table. "Last seen at 4%" is often the single most useful fact on the screen — it tells you whether to expect another fix at all. ## The platforms ### iOS - **`Always` location authorization** plus the `location` background mode and `allowsBackgroundLocationUpdates`. `When In Use` is not enough; a find-my that only works while you are looking at it is not one. - **Significant-change monitoring** is the floor: roughly cell-tower granularity, very cheap, survives app termination, and iOS relaunches the app to deliver it. This should be the primary source. - **The Visits API** is cheaper still and gives arrival/departure at meaningful places. Good for history, useless for "it is moving right now". - **Continuous GPS is not an option** for background use. It will eat the battery and iOS will fight you. - The app is self-distributed, so App Store review of background location is not the obstacle it would otherwise be. ### Android and GrapheneOS **Easier, and there is a pleasing irony here.** The foreground service holding a tailnet socket — the approach `docs/push-notifications.md` records as the _fallback_ for GrapheneOS having no Play Services — is close to ideal for this. It is always connected, so it needs no push to be told to report, and the persistent notification that made it unattractive for notifications is a normal thing for a location service to have. So on the phone where push is hardest, find-my is easiest. One foreground service can serve both. ### Laptops and desktops Do not write a client. **headscale already knows** `online` and `lastSeen` per node, and the endpoint addresses give a coarse network location. That is most of what anyone wants for a laptop, and it exists today. ## Commands, beyond locating Each needs app code — **the notification catalogue makes new notifications free, not new behaviours.** - **Locate now** — the weak path above. - **Play a sound.** Genuinely useful, and the "lost in the house" case is the most common of all. Needs an audio session category that overrides the silent switch. - **Mark as lost** — show a message and a contact number on the lock screen. Needs MDM on iOS. - **Lock / erase.** Real MDM. Officer already serves iOS configuration profiles (`claimIosProfile` in `api/dav/ios-profile`), so this is a project rather than an absurdity — but it is a project, and it puts a device-wipe capability behind whatever authenticates to it. Think hard before building it. ## Retention, and the thing to decide first **A location table on your own server is a complete movement record of your life.** Apple deliberately cannot build one; you would be choosing to. That is a legitimate choice, and it is the whole point of self-hosting — but it means the retention rule is a design decision, not a settings screen to add later. Decide before the table exists: - **A default retention window** with an actual delete job, not a documented intention. 30 days is a reasonable starting point: enough to answer "where was I on Tuesday", short enough that a compromise is not a biography. - **A pause switch** that stops reporting, visible on the device, honest about what it does. - **What a backup contains.** The Postgres backup now includes a movement history. Anyone who copies a dump copies that. - **Who can see whom.** Location is per-user and every query filters by `user_id` — but **the owner bypasses every permission check by design** (`permissions/authorize.ts`). So on a family server the owner can see a member's location, structurally, unless location queries are given an explicit exception. That is a policy decision that has to be made deliberately, because the default falls out of the existing architecture rather than out of anyone's intent. That last one is the sharpest question in this document. ## Permissions A new registry entry, `kind: 'app'` so it is grantable per role, with `personal` covering the report endpoint so a member's own device can post its own fixes at read level — the same shape (and the same trap) as `notify`, where the `personal` paths must be the real post-prefix paths. See `permissions/registry.ts` and the `notify` regression test. `assertPermissionTotality` will refuse to boot without the entry, which is the intended behaviour. ## The web surface Per the frontend route conventions in `CLAUDE.md`: a `/find-my` route rendering `WorkspaceView`, with panels rather than a standalone screen. - **Device list** — rows are real ``s to `/find-my?selected=`, never onClick closures. - **Map panel** — reads `?selected` from the URL, not from a channel. Reuse the maplibre setup from `plugins/photos/web/MapSection.tsx`. - **History/timeline panel** — a date range, also in the URL. Selection is the URL. `docs/navigation-audit.md` is the authority and names the anti-pattern this must avoid. ## Core or plugin **Probably a plugin**, unlike notify. The argument for notify being core was that its producers are the platform's own — the job queue, an agent turn ending. Nothing in the platform produces a location. It is a self-contained feature with its own table, its own panel and its own app support, which is the plugin shape exactly. The counter-argument is that it needs a stable `devices` table that other things will want (push, presence, per-device settings), and a core concept living in a plugin is how the notify mess started. **Split it:** `devices` is core, find-my is a plugin that hangs off it. ## Build order 1. **`devices` table, core**, with the on-device UUID, and `push_devices.device_id` joined to it. This is worth doing even if find-my never ships — the push registry currently has no stable device identity. 2. **Report endpoint + `device_locations`.** Batched, both timestamps, accuracy required. 3. **Hub app: significant-change reporting.** The floor, and on its own already a working find-my. 4. **The panel** — device list, map, last-known position. 5. **Retention job.** Before real data accumulates, not after. 6. **Play a sound.** The highest value-per-effort command by a distance. 7. **On-demand locate**, accepting it is best-effort. 8. Android foreground service, folded in with the push fallback if that is ever built. 9. MDM tier — separately, and only if genuinely wanted. ## Open questions - **Owner visibility of a member's location.** The default falls out of the permission architecture rather than out of intent. Decide it explicitly. - **Retention default**, and whether it is per-device. - **Can an NSE take a location fix?** Would make on-demand reliable. Unverified, worth an afternoon. - **Geofences** — "tell me when the phone leaves the house" is cheap on both platforms and is arguably more useful than on-demand locating. Not in the build order above; it might deserve to be.