diff --git a/CLAUDE.md b/CLAUDE.md
index db754599..8c6db9d9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -421,6 +421,9 @@ explaining why it was safe.
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
a severity-ranked findings table, the channel-selection map and the four-phase plan
+- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
+ assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
+ question to settle first, which is retention rather than anything technical
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
agent. Read before writing one
diff --git a/docs/find-my-device.md b/docs/find-my-device.md
new file mode 100644
index 00000000..4e503c69
--- /dev/null
+++ b/docs/find-my-device.md
@@ -0,0 +1,269 @@
+# 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.