# 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.