# Build spec — DadFinder emergency line

## The ask
Isaac's dad needs to reach him and be sure he was reached. Dad must do nothing new: no app, no link, no
QR, no access key. Isaac's phone rings at full volume even in silent mode / DND, and Dad sees confirmation
rather than guessing.

## Shape
- One Cloudflare Worker (`dadfinder.isaac1804.workers.dev`) + one Durable Object hub holding the ring
  state, plus the dad page served from the same worker.
- One native Android app on Isaac's phone (no Gradle: shell + javac + aapt2 + apksigner) holding a
  foreground service that long-polls the hub and a full-screen-intent alarm activity.
- The website is the only surface Dad touches. The app is installed once, by Isaac.

## Contract (asserted in the simulator's self-test)
1. One ring id is offered to every connected phone, not consumed by the first poller.
2. No phone listening → `409 refused:offline`; never queue a ring that would fire minutes later.
3. Turning the line off posts `stage=off` and the hub reports offline immediately.
4. Rate limit: one press per 20 s per IP (`429 too soon`).
5. The phone acknowledges `stage=start` when the alarm starts and `stage=stop` when it is closed.
6. DND is lifted and the alarm stream is raised to max before the siren starts.

## Failure modes found and fixed
- **Ring stolen by a second device.** The hub marked a ring `consumed` on first delivery, so a test
  emulator swallowed the ring meant for Isaac's phone. Fixed with per-install client ids and a
  `deliveredTo` list.
- **"Online" was sticky.** Offline only appeared after a 90 s poll timeout, and any device kept it warm.
  Fixed with an explicit `stage=off` ack.
- **Queued surprise ring.** A press with no phone listening was parked for 3 minutes and fired when the
  line came back. Now refused outright.
- **Mobile data could not arm the line.** The app dialled the IPv6 address of the worker and died there
  (`ConnectException … 2606:4700:…:443`); carriers hand out an IPv6 route that does not work. Fixed by
  pinning IPv4 before any socket opens.
- **Double alarm.** One ring produced two alarm screens; `RingActivity.onPause()` re-posted the
  full-screen intent. Fixed by retaining `shownAt` across pause/resume.

## Verified
- Emulator: DND total silence (`zen=2`) → `dnd filter was 3, lifted`, `alarm volume 0 -> 7 / max 7`,
  `siren playing`, `ack stop -> 200`.
- Killed process (`kill -9`): START_STICKY respawn in 3 s; a persisted JobScheduler watchdog restores the
  service within 15 min when it does not.
- Two pollers, one ring: both receive the same id.
- Simulator: 9/9 checks, zero network requests, no console errors, no horizontal overflow at 390/768/1440.

## Not built
- No iOS app. No SMS fallback. The watchdog cannot beat a force-stop; the card says so.
