## Context Phase 2's alert throttle (250 m / 10-min window) blocks repeat alerts from the same spot but doesn't distinguish *finders* — multiple people at the same spot only get one alert, and one device can re-alert by moving around. This change adds (A) a different-finder re-alert rule and (B) a 24 h per-device block via browser fingerprinting. Anti-spam motivation mirrors the 2014 system (which tracked fingerprint + IP on every alert). ## Goals / Non-Goals **Goals:** - Every distinct finder who leaves a phone number reaches the owner (within the window). - One device cannot spam the owner more than once per 24 h. - Minimal dependency footprint: hand-rolled fingerprint hash, no third-party JS. **Non-Goals:** - Identity-grade fingerprinting (this is anti-spam, not authentication; a determined attacker can fake signals). - IP-based blocking (privacy + NAT false positives) — fingerprint only. - Rate limits tied to plans/billing (Phase 5). ## Decisions 1. **Fingerprint = lightweight hand-rolled hash** of `navigator.userAgent`, `language`, `timezoneOffset`, `screen WxH`, `platform`, `hardwareConcurrency`. JS computes a 32-bit hash → base36 string. *Alternative:* fingerprintjs-lite (heavier, more robust) — deferred; the hand-rolled version is dependency-free and adequate for anti-spam; upgradeable later. 2. **Fingerprint block is checked FIRST** in `shouldAlert` (before the 10-min throttle): a fingerprint seen in the last 24 h ⇒ record-only. *Rationale:* the same device is the same person regardless of which location/phone they present. 3. **Different-finder rule lives inside the 10-min window branch**: within the window, re-alert if `moved > 250 m` OR (`newPhone != ""` AND `newPhone != lastAlert.scanner_phone`). Outside the window, alert as before. 4. **Contact path dedup**: `FinderContact` queries the last alerted scan; within the window and same phone ⇒ store-only (no SMS). Different phone ⇒ SMS. Fingerprint present and seen within 24 h ⇒ store-only. 5. **Schema**: `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;` (idempotent, nullable). New sqlc query `GetRecentScanByFingerprint` (`WHERE fingerprint = $1 AND scanned_at > now() - interval '24 hours' ORDER BY scanned_at DESC LIMIT 1`). 6. **ScanRequest gains optional `phone` + `fingerprint`**; both flow into `InsertScan`. ## Risks / Trade-offs - [Fingerprint is spoofable] → accepted (anti-spam, not security); document as such. - [Different-phone rule could alert twice from one person with two numbers] → acceptable; the owner sees both contacts and the 24 h fingerprint block caps same-device repeats. - [Fingerprint column grows] → TEXT, nullable, negligible. ## Migration Plan 1. `make db-up` (idempotent ALTER) → `scans.fingerprint`. 2. sqlc `make generate` (new query + column in model). 3. Deploy code + JS; no data migration needed. ## Open Questions - None blocking. (24 h window is a constant; adjustable later.)