diff --git a/.planning/v15-ios-handoff/06-ios-responsibilities.md b/.planning/v15-ios-handoff/06-ios-responsibilities.md new file mode 100644 index 000000000..f7b1fe59c --- /dev/null +++ b/.planning/v15-ios-handoff/06-ios-responsibilities.md @@ -0,0 +1,398 @@ +--- +file: 06-ios-responsibilities.md +purpose: Exhaustive list of what the native iOS app owns — everything the server cannot or will not do for the client. +when_to_read: First, before any iOS implementation. Defines the boundary so iOS Claude doesn't accidentally reinvent server features (see 07-server-responsibilities.md for the inverse). +prerequisites: 03-system-overview.md, 04-data-model.md, 05-auth-flows.md +estimated_tokens: ~4500 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# iOS Responsibilities + +## TL;DR + +Five domains the iOS app owns end-to-end: (1) HealthKit ingestion + permissions, (2) Keychain storage of the bearer token + biometric unlock, (3) APNs registration + payload handling, (4) offline cache + sync queue, (5) deep linking into server-rendered pages. Everything else (AI, analytics, persistence, scheduling) goes through the server. + +## STOP HERE IF + +- You think iOS should run its own AI / analytics / persistence layer. It should not — see `07-server-responsibilities.md` for what's already done. +- You think iOS should encrypt anything beyond the bearer token + the offline-cache blob. It should not — the server already encrypts at rest. +- You think iOS should write its own measurement validation. It should not — `mapAppleHealthEntry()` server-side is authoritative. + +## Domain 1 — HealthKit ingestion + +This is the **single largest iOS-only responsibility**. The server has no access to HealthKit; the iOS app is the only path Apple Health → HealthLog. + +### What iOS reads from HealthKit + +| MeasurementType (server) | HKQuantityTypeIdentifier (HealthKit) | DB unit | Aggregation | +| ------------------------------- | ----------------------------------------------- | ------------------ | ----------- | +| `WEIGHT` | `HKQuantityTypeIdentifierBodyMass` | kg | latest | +| `BODY_FAT` | `HKQuantityTypeIdentifierBodyFatPercentage` | % | latest | +| `BODY_TEMPERATURE` | `HKQuantityTypeIdentifierBodyTemperature` | celsius | latest | +| `BLOOD_PRESSURE_SYS` | `HKQuantityTypeIdentifierBloodPressureSystolic` | mmHg | latest pair | +| `BLOOD_PRESSURE_DIA` | `HKQuantityTypeIdentifierBloodPressureDiastolic`| mmHg | latest pair | +| `PULSE` | `HKQuantityTypeIdentifierHeartRate` | bpm | mean/latest | +| `RESTING_HEART_RATE` | `HKQuantityTypeIdentifierRestingHeartRate` | bpm | latest | +| `HEART_RATE_VARIABILITY` | `HKQuantityTypeIdentifierHeartRateVariabilitySDNN` | ms | latest | +| `OXYGEN_SATURATION` | `HKQuantityTypeIdentifierOxygenSaturation` | % (×100 from HK) | latest | +| `BLOOD_GLUCOSE` | `HKQuantityTypeIdentifierBloodGlucose` | mg/dL | latest | +| `ACTIVITY_STEPS` | `HKQuantityTypeIdentifierStepCount` | count | sum | +| `ACTIVE_ENERGY_BURNED` | `HKQuantityTypeIdentifierActiveEnergyBurned` | kcal | sum | +| `FLIGHTS_CLIMBED` | `HKQuantityTypeIdentifierFlightsClimbed` | count | sum | +| `WALKING_RUNNING_DISTANCE` | `HKQuantityTypeIdentifierDistanceWalkingRunning`| metres | sum | +| `VO2_MAX` | `HKQuantityTypeIdentifierVO2Max` | mL/(kg·min) | latest | +| `SLEEP_DURATION` | `HKCategoryTypeIdentifierSleepAnalysis` | minutes | sum-by-stage | +| `AUDIO_EXPOSURE_ENV` | `HKQuantityTypeIdentifierEnvironmentalAudioExposure` | dBA | mean | +| `AUDIO_EXPOSURE_HEADPHONE` | `HKQuantityTypeIdentifierHeadphoneAudioExposure`| dBA | mean | +| `TIME_IN_DAYLIGHT` | `HKQuantityTypeIdentifierTimeInDaylight` (iOS 17+) | minutes | sum | + +Source-of-truth on the server side: `src/lib/measurements/apple-health-mapping.ts`. Add a row there if iOS starts sending a new identifier; do NOT silently start sending data for a type the server doesn't know — it will return `status: skipped` per entry. + +### Unit conversion — iOS pre-multiplies before sending + +Two HealthKit quantities ship as 0..1 fractions; the server expects them as percentages: + +- `HKQuantityTypeIdentifierOxygenSaturation` → multiply by 100 before sending. +- `HKQuantityTypeIdentifierBodyFatPercentage` → multiply by 100 before sending. + +For sleep: + +- `HKCategoryTypeIdentifierSleepAnalysis` ships per-stage rows. The category value (HKCategoryValueSleepAnalysis) is an integer (`inBed = 0`, `asleepUnspecified = 1`, `asleepCore = 3`, `asleepDeep = 4`, `asleepREM = 5`, `awake = 2`). Send the integer in the batch as `sleepStage` and let the server look up the canonical enum via `APPLE_HEALTH_SLEEP_STAGE_MAP`. + +### Permissions per metric type + +`HKHealthStore.requestAuthorization(toShare:read:)` — the iOS app asks the user **per type**, never one bulk grant. Group the request by metric category in the onboarding UX (vitals / activity / sleep / mood / etc.) so the user sees logical permission screens. + +```swift +// Per-type read permissions (sample subset; expand to match the table above) +let readTypes: Set = [ + HKObjectType.quantityType(forIdentifier: .bodyMass)!, + HKObjectType.quantityType(forIdentifier: .heartRate)!, + HKObjectType.quantityType(forIdentifier: .stepCount)!, + // ... +] +healthStore.requestAuthorization(toShare: [], read: readTypes) { (success, error) in /* ... */ } +``` + +iOS **never writes back to HealthKit** in v1.5 — read-only flow. (Write-back is on the v1.6 backlog.) + +### Sample-types iOS supports vs what the enum knows + +The server enum `MeasurementType` is the contract. Migrations 0051-0053 prepped server-side. iOS supports the subset above. **When Apple adds a new HK identifier (e.g. iOS 18's hypothetical new metric), iOS does NOT silently start sending it** — file a server-side change first (one entry in `apple-health-mapping.ts`), then enable on iOS. + +The server returns `{ status: "skipped", reason: "unknown_hk_identifier" }` per entry the mapper doesn't know. iOS uses this to decide whether to advance its sync cursor past the row (yes — never retry it). + +### Observer pattern + sync cadence + +```swift +let predicate = HKQuery.predicateForSamples(withStart: lastSyncDate, end: nil) +let query = HKObserverQuery(sampleType: type, predicate: predicate) { _, _, _ in + self.drainQueue() +} +healthStore.execute(query) +healthStore.enableBackgroundDelivery(for: type, frequency: .immediate) { _, _ in } +``` + +`drainQueue()` accumulates pending samples and posts a batch to `POST /api/measurements/batch` up to **500 entries per batch** (server-enforced cap). Rate limit: 60 batches per user per minute (= 30 000 rows/min headroom). + +When the batch crosses 50 entries the server fires PR detection in `silent: true` mode — backfills don't spam pushes. + +### Sync conflict resolution — when HK and Withings disagree + +The server's two-axis source-priority picker resolves conflicts. See `16-health-score-logic.md` and `07-server-responsibilities.md`. iOS does NOT pre-resolve. + +When iOS sees the same physical reading both in HealthKit (synced from a Withings Withings Health Mate app) and via the Withings direct integration, both rows land in the DB. The picker keeps one per day for cumulative metrics; for point metrics the latest of the winning source wins. + +**iOS responsibility:** tag each sample with the canonical source = `APPLE_HEALTH` (per the `MeasurementSource` enum spelling — NOT `HEALTHKIT`) and the `deviceType` ∈ `watch | band | ring | phone | scale | other | unknown` derived from `HKDevice.model` on the sample. + +iOS does NOT pick a winner. The server does. + +## Domain 2 — Keychain + biometric unlock + +### Bearer token storage + +The user signs in with username + password (or passkey) → server returns a bearer token via `/api/auth/login` or `/api/auth/passkey/login-verify`. iOS stores it in **Keychain** with: + +- `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` — token is unlocked only when the device is unlocked, never syncs to iCloud, never restored to a different device. +- `kSecAttrAccount` = user email (so multi-account is trivial). +- Optional `LAContext` biometric prompt on first read of the day (Face ID / Touch ID). + +### Token refresh + +Tokens have a server-enforced 30-day TTL (see `05-auth-flows.md`). The iOS app silently refreshes via `POST /api/auth/refresh` on every app foreground when the token is within 7 days of expiry. On a 401 response from any other endpoint, fall back to the full login flow. + +### Codex / OAuth tokens + +If the user has connected ChatGPT OAuth on the web, the OAuth tokens are server-side. iOS does NOT see them. The iOS app only sees its own bearer token. + +### What goes in iOS Keychain + +| Item | When written | Accessibility | +| ----------------------------- | ------------------------- | ---------------------------------------------- | +| Bearer token | On login success | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | +| Last-known refresh-token hint | On token refresh response | same | +| User email | On login success | same (or `UserDefaults` if you accept the leak; Keychain is safer) | +| Last HK sync cursor | On batch sync success | `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (needed for background sync) | + +What does NOT go in iOS Keychain: + +- Any provider API key (Anthropic, OpenAI, etc.) — those live server-side. +- Any user health data — that lives server-side. +- The user's password — once they're signed in, the bearer token is all you keep. + +## Domain 3 — APNs + +The iOS app handles APNs registration + payload reception. Server-side scaffolding exists (`src/lib/notifications/senders/apns.ts`). + +### Registration flow + +1. App start → `UIApplication.registerForRemoteNotifications()`. +2. iOS callback → `application:didRegisterForRemoteNotificationsWithDeviceToken:` — receive an opaque `Data` blob. +3. Convert to hex string (Apple's spec — typically 64 chars). +4. POST to `/api/devices`: + +```json +POST /api/devices +{ + "token": "", + "bundleId": "com.healthlog.ios", + "locale": "en", + "appVersion": "1.5.0", + "model": "iPhone15,3", + "apnsToken": "", + "apnsEnvironment": "production" +} +``` + +The contract (Zod-validated): + +```ts +// from src/app/api/devices/route.ts +deviceSchema = z.object({ + token: z.string().min(8).max(512).regex(/^[A-Za-z0-9+/=._:-]+$/), + bundleId: z.string().min(1).max(128), + locale: z.string().optional(), + appVersion: z.string().optional(), + model: z.string().optional(), + apnsToken: z.string().regex(/^[A-Fa-f0-9]+$/).optional(), + apnsEnvironment: z.enum(["sandbox", "production"]).optional(), +}).refine(/* apnsToken + apnsEnvironment supplied together or both omitted */); +``` + +Important: + +- `apnsEnvironment` = `sandbox` for Debug builds (Xcode), `production` for TestFlight + Release. The iOS app picks — the server can't tell from the token alone. +- The composite uniqueness of `apnsToken` is enforced server-side. Re-registering a token under a different user → 409. APNs tokens are not secrets, but trusting wire input would let an attacker who learns one redirect another user's pushes. + +### APNs payload schema + +```json +{ + "aps": { + "alert": { "title": "Personal Record", "body": "New all-time best: 8,420 steps" }, + "badge": 1, + "sound": "default", + "thread-id": "personal_record" + }, + "eventType": "PERSONAL_RECORD", + "metricType": "ACTIVITY_STEPS", + "value": 8420, + "deepLink": "healthlog://personal-records/cmp-2026-05-13-steps" +} +``` + +`eventType` ∈ `MEDICATION_REMINDER | MEASUREMENT_ANOMALY | COMPLIANCE_LOW | WITHINGS_SYNC_FAILED | SYSTEM_ALERT | PERSONAL_RECORD`. + +`PERSONAL_RECORD` is OFF by default per user (`EVENT_DEFAULT_ENABLED.PERSONAL_RECORD = false`) — a multi-year HK backfill on first install would otherwise saturate the lock screen. + +### When iOS handles tap + +The `deepLink` in the payload (custom URL scheme `healthlog://`) routes the user to the relevant in-app page. For surfaces not yet on iOS, redirect to the web URL. + +## Domain 4 — Offline cache + sync queue + +iOS works offline-first. The HK observer fires whether or not the network is up; the iOS app queues pending batches in Core Data (or SQLite, designer's choice). + +### Sync queue shape + +```swift +struct PendingBatch { + let id: UUID // Idempotency-Key candidate + let entries: [BatchEntry] // ≤ 500 + let queuedAt: Date + let attempts: Int + let lastError: String? +} +``` + +### Idempotency contract + +Each batch POST carries an `Idempotency-Key` header. The server's idempotency middleware caches the response for 24 hours; an HTTP-level retry replays the cached response. iOS uses this when reconnecting after a flaky network. + +```swift +var request = URLRequest(url: batchUrl) +request.setValue(batch.id.uuidString, forHTTPHeaderField: "Idempotency-Key") +// ... +``` + +Per-entry idempotency (server-enforced via the `(user_id, type, source, external_id)` composite unique index): two devices uploading the same HealthKit sample produce `status: "duplicate"` on the second post, not an error. The iOS client advances its sync cursor past both. + +### Offline read cache + +What iOS caches locally for offline display: + +| Data | Storage | TTL | +| --------------------------------- | --------------- | ------------------ | +| Last `HealthScoreResult` | UserDefaults / Core Data | until next sync | +| Last `AIInsightResponse` per page | Core Data | until next sync | +| Recent measurements | Core Data | rolling 90 days | +| Medications + intake schedule | Core Data | until next sync | +| User profile (`User.timezone`, locale) | Keychain | until next sign-in | + +When offline, render from cache with a banner: "Showing last synced data — Tue 13 May, 14:22". + +When the iOS app is online again, **the server is the source of truth** — re-fetch and replace cache, never merge. + +## Domain 5 — Deep linking + +The web app has rich URL structure (`/medications/[id]/history`, `/insights/blood-pressure`, `/settings/notifications`). The iOS app deep-links into these via: + +1. **Native screens** — the iOS app has its own Swift versions of dashboard / Coach / Insights / Settings. +2. **Web embed** — for low-traffic settings pages the iOS team chooses not to port in v1.5, embed the web view with the bearer token forwarded via a one-time login URL. + +The custom URL scheme `healthlog://` lets APNs payloads + share-sheet URLs route into specific screens. + +### Recommended scheme + +| URL | Routes to (iOS) | +| ------------------------------------------------ | ---------------------------------------- | +| `healthlog://dashboard` | Dashboard tab | +| `healthlog://coach` | Coach tab | +| `healthlog://insights/blood-pressure` | BP sub-page | +| `healthlog://medications/{id}` | Medication detail | +| `healthlog://medications/{id}/history` | (v1.5: web embed; v1.6: native screen) | +| `healthlog://personal-records/{recordId}` | PR detail | +| `healthlog://settings/notifications` | Notification preferences | + +Universal Links (HTTPS) are a nice-to-have on top of the custom scheme but not required for v1.5. + +## Settings / preferences sync + +The server owns the user's preferences (CoachPrefs, locale, timezone, notification preferences). iOS reads + writes via: + +- `GET /api/user/profile` — full user shape. +- `PATCH /api/user/profile` — partial update. +- `GET /api/insights/settings` — provider chain + Coach prefs. +- `PATCH /api/insights/settings` — same. +- `GET /api/notifications` — channel + event preferences. +- `PATCH /api/notifications` — same. + +Local-only iOS settings (e.g. "use biometric on app open", "show HK sync progress indicator") live in `UserDefaults` and do NOT sync to the server. + +## App-store metadata (out of scope for this doc) + +- App ID, bundle ID, capabilities (HealthKit, Push, Background fetch). +- Privacy nutrition labels. +- ATS exception entries if any (none expected — server is HTTPS). +- TestFlight beta groups. + +## Background tasks + +iOS uses `BGAppRefreshTask` + `BGProcessingTask` for periodic sync: + +```swift +// Register on app launch: +BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.healthlog.sync", using: nil) { task in + self.handleSync(task: task as! BGAppRefreshTask) +} + +// Schedule on app background: +let request = BGAppRefreshTaskRequest(identifier: "com.healthlog.sync") +request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min +try? BGTaskScheduler.shared.submit(request) +``` + +The HK observer keeps fresh data flowing without explicit scheduling, but `BGProcessingTask` is the catch-net for offline-mode catch-up + cache refresh. + +## What iOS does NOT do + +| Concern | iOS does NOT | Server does | +| ----------------------------------- | ------------------------------------------- | -------------------------------------- | +| AI provider calls | Never (no SDK) | All Insight / Coach / Briefing | +| Insight generation | Never | `provider-runner.ts` | +| Prompt building | Never | `src/lib/ai/coach/`, `src/lib/ai/prompts/` | +| Refusal heuristics | Never (server returns refused-prose) | `src/lib/ai/coach/refusal.ts` | +| Safety contract enforcement | Never | `safety-contracts.*.yaml` | +| Source-priority resolution | Never | `src/lib/analytics/source-priority.ts` | +| Health-score math | Never (renders only) | `src/lib/analytics/health-score.ts` | +| Withings sync | Never | `src/lib/withings/` | +| PR detection | Never | `src/lib/personal-records/` | +| GLP-1 PK math | Never | `src/lib/medications/glp1-pk.ts` | +| Medication titration ladder | Never | server | +| Audit log | Never | `src/lib/auth/audit.ts` | +| Rate limiting | Never (server enforces; iOS handles 429) | `src/lib/rate-limit.ts` | +| i18n translation | App-bundle only (sees `messages/.json` via SwiftGen or hand-port) | server-translator + flat files | +| Personal Record detection | Never | `pr-detection-worker.ts` | +| Notification dispatcher | Never (iOS handles APNs payload only) | `notifications/dispatcher.ts` | + +## MDR-critical iOS warnings + +The iOS app could accidentally cross the MDR line by: + +1. **Generating its own Coach reply / Insight** — if iOS calls Anthropic/OpenAI directly with the user's data, it bypasses every safety contract. **Don't.** +2. **Summarising Coach replies** — the MI tone + disclaimer + evidence block are the safety surface. A "smart summary" that drops them violates the contract. **Don't.** +3. **Drug-level chart in a widget / Siri shortcut** — the Research-mode AreaChart is gated by a disclaimer + acknowledgment dialog. A widget that surfaces the curve without the dialog crosses the line. **Don't.** +4. **iOS-side "regenerate" of a cached Insight without provider call** — if iOS shows a fabricated "regenerated" reply (e.g. randomly permuting findings), it's no longer evidence-grounded. **Don't.** +5. **iOS-side editing of medication doses** — the medication detail screen on iOS may ALLOW the user to log + edit their OWN entries, but it must NEVER offer recommendations or smart-fill "next dose" values. **Don't.** +6. **Apple Watch complication that shows a drug-level chip** — same line as #3. The phase chip is gated by Research Mode acknowledgment; a complication that shows it without the dialog is a violation. **Don't.** + +When in doubt: **the iOS app is a thin client over server-rendered intelligence**. The server is the medical-information custodian; iOS is the presentation layer + the HK adapter. + +## "Since v1.4.24" diff markers + +- **NEW v1.4.25** — `MeasurementType` enum gained `AUDIO_EXPOSURE_ENV`, `AUDIO_EXPOSURE_HEADPHONE`, `TIME_IN_DAYLIGHT` for HK + Withings parity. +- **NEW v1.4.25 W8c** — `Measurement.deviceType` column; iOS now SHOULD send `deviceType` per row (optional, defaults to `null` = unknown). +- **NEW v1.4.25 W16c** — `PERSONAL_RECORD` event type for APNs; default OFF. +- **NEW v1.4.25 W10** — batch ingest rate limit 60/min per user (was unbounded). +- **NEW v1.4.25 W17b/c** — Withings now syncs Activity v2 + Sleep v2 — iOS should be aware that some HK metrics may arrive via the Withings path (no action required, but the source-priority picker now matters more). + +## iOS implementation checklist + +1. **HealthKit observer** for every supported sample type; pre-multiply OxygenSaturation and BodyFatPercentage by 100; tag `deviceType` from `HKDevice.model`. +2. **Batch sync** with `Idempotency-Key` header, ≤500 entries per batch, retry on network error. +3. **Keychain bearer** with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`; biometric prompt optional. +4. **APNs register** on launch, post to `/api/devices` with `apnsToken` + `apnsEnvironment`. +5. **Offline cache** for Health Score + last Insight + recent measurements; render from cache when offline with banner. +6. **Deep link** custom scheme `healthlog://` for APNs taps + share-sheet URLs. +7. **No LLM SDK** in the iOS bundle. Period. +8. **No medication recommendation surface** anywhere in the iOS UI. Period. + +## Self-test snippet + +```bash +# Verify the iOS-batch-ingest contract from curl: +curl -X POST http://localhost:3000/api/measurements/batch \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: $(uuidgen)" \ + -d '{ + "entries": [{ + "hkIdentifier": "HKQuantityTypeIdentifierStepCount", + "value": 8420, "unit": "count", + "startDate": "2026-05-13T08:00:00Z", + "endDate": "2026-05-13T20:00:00Z", + "externalId": "uuid-from-HKSample-uuid", + "deviceType": "watch" + }] + }' +# Expected: { "results": [{ "index": 0, "status": "inserted" }] } +``` + +## Cross-references + +- **04-data-model.md** — `Measurement` table schema, `Device` table schema. +- **05-auth-flows.md** — bearer token lifecycle + refresh + passkey. +- **07-server-responsibilities.md** — the inverse — everything the server already does for you. +- **14-coach-mental-model.md** — Coach is server-resident; iOS is a streaming client. +- **08-locked-contracts.md** — exact request/response schemas. diff --git a/.planning/v15-ios-handoff/07-server-responsibilities.md b/.planning/v15-ios-handoff/07-server-responsibilities.md new file mode 100644 index 000000000..7a8918551 --- /dev/null +++ b/.planning/v15-ios-handoff/07-server-responsibilities.md @@ -0,0 +1,364 @@ +--- +file: 07-server-responsibilities.md +purpose: Exhaustive list of what the server already handles — so iOS doesn't reinvent any of it. +when_to_read: Right after 06-ios-responsibilities.md, before any iOS feature design. The inverse of that doc. +prerequisites: 03-system-overview.md, 04-data-model.md +estimated_tokens: ~4500 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# Server Responsibilities + +## TL;DR + +Everything except the HealthKit adapter, the Keychain, the APNs token, the offline cache, and the iOS UI. The server owns AI prompts + safety contracts + provider routing, Insights generation, PR detection, GLP-1 PK helper, Withings sync (Activity + Sleep + measurement + medication), Health Score, source-priority resolver, audit log, rate limiter, i18n, notifications, and the API token store. iOS reads results; iOS does not reimplement. + +## STOP HERE IF + +- You're tempted to recompute the Health Score on the iOS side. Don't — see `16-health-score-logic.md`. +- You're tempted to build a local Withings poll. Don't — the cron runs server-side at :00 and :15. +- You're tempted to fork the i18n strings to a Swift bundle without sync. Source-of-truth is `messages/.json` on the server; iOS reads + ships those. + +## Domain 1 — Coach AI + +### What the server does + +- **Per-locale system prompts** — DE (hand-curated), EN (authoritative), FR/ES/IT/PL (native build via the matrix). Source: `src/lib/ai/coach/system-prompt.ts`. +- **GROUND RULES 1-15** — all 15 ratchet through every Coach call. The safety contract holds the EU MDR line (rules 9, 10, 15 explicitly). +- **PROMPT_VERSION** — `4.25.0` stamped into every prompt + every Wide-Event log entry. Source: `src/lib/ai/prompts/insight-generator.ts:34`. +- **Coach Snapshot composition** — per-turn data extracted from the user's measurements via `buildCoachSnapshot()`. Stripped of PII, anchored to `User.timezone`, GLP-1 block conditionally added. Source: `src/lib/ai/coach/snapshot.ts`. +- **Refusal heuristics** — pattern bank for prompt injection + off-topic. Source: `src/lib/ai/coach/refusal.ts`. +- **Multi-provider routing** — codex → openai → anthropic → local → admin-openai chain. Source: `src/lib/ai/provider.ts`. +- **Streaming response** — SSE token frames + provenance event + done. Endpoint: `POST /api/insights/chat`. +- **Evidence-block sentinel parser** — strips `---KEYVALUES---` block from prose, populates `provenance.keyValues`. Source: `src/lib/ai/coach/keyvalues.ts`. +- **Refusal-probe matrix** — >1800 adversarial probes across 6 locales × 15 rules, tested in CI. + +### What iOS does + +Build a streaming chat UI over the SSE endpoint. Nothing else. See `14-coach-mental-model.md` for the full mental model. + +## Domain 2 — Insights generation + +### What the server does + +- **Per-page system prompts** — `general-status.ts`, `blood-pressure.ts`, `weight.ts`, `pulse.ts`, `mood.ts`, `bmi.ts`, `medication-compliance.ts`. Each layers a DOMAIN section on top of the shared `base-system.ts`. +- **JSON schema validation** — `aiInsightResponseSchema` (Zod). The wrapper retries once on schema failure with a corrective system message; 422 on second failure. Source: `src/lib/ai/generate-insight.ts`. +- **Citation-coverage check** — every `metricSource` must appear in `citations[]`. Enforced by `findUncitedRecommendations()`. +- **Confidence score** — `computeConfidence()` blends model-side confidence with citation-coverage % to produce a 0..1 score the UI surfaces. +- **Cache** — per-user-per-page-per-locale rows in `AiInsightHistory`. Reads serve cached JSON; writes invalidate downstream. +- **`dailyBriefing` slot** — optional top-level block in the comprehensive Insight; 80-200 words + up to 5 key findings. +- **`weeklyReport` slot** — optional ISO-week block; summary + goingWell + worthWatching + tips + dataQualityNotes. +- **`storyboardAnnotations`** — date-pinned events on the 90-day BP timeline. +- **`trendAnnotations`** — one-sentence observation per metric, rendered below mini-charts. +- **Inline-chart tokens** — the prompt emits `metric:` strings the UI replaces with charts. + +### What iOS does + +Render the JSON. Show citations. Replace inline-chart tokens. See `15-insights-architecture.md`. + +## Domain 3 — Health Score + +### What the server does + +- **Composite formula** — 4 pillars × weights; redistribution on null. Source: `src/lib/analytics/health-score.ts`. +- **Deterministic `asOf`** — post-Fix-G; no `Date.now()` in the math. +- **Source attribution per component** — `bp/weight/mood/compliance` × `manual/withings/appleHealth/mixed/none`. +- **Weekly delta** — `previous?` input → `delta = score - previous.score`. +- **Bands** — `green >= 75`, `yellow 50..74`, `red < 50`. + +### What iOS does + +Render the number, the band colour, the delta, and the provenance accordion. See `16-health-score-logic.md`. + +## Domain 4 — PR (Personal Record) detection + +### What the server does + +- **pg-boss queue** `pr-detection`, concurrency=5. Source: `src/lib/jobs/pr-detection.ts`. +- **Worker handler** — `detectPersonalRecordsForUser()` scans the user's full measurement + workout history per metric/slot, picks the all-time best per direction (MAX/MIN), writes to `PersonalRecord`. Source: `src/lib/personal-records/pr-detection-worker.ts`. +- **Warm-up gate** — minimum 7 samples per metric before any record is written. Stops an Apple Health backfill from locking in a freak first-row as "all-time best". +- **6 workout slots** — `longest_run_duration`, `longest_distance_run`, `fastest_5km_time`, `longest_cycle_duration`, `longest_distance_cycle`, `fastest_5km_cycle`. Workout PRs piggy-back on the same module via the `metricSlot` discriminator. +- **30-minute fallback cron** — `*/30 * * * *` re-scans every user. Catch-net for any ingest path that forgot to enqueue. +- **Per-batch enqueue** — `/api/measurements/batch` calls `enqueuePrDetection(userId, { silent })` after every successful batch insert. `silent = entries.length > 50` so large backfills don't fire pushes. +- **Tie handling** — equal-value writes a row but suppresses the push. +- **Idempotency** — `(user, metric, slot, achievedAt)` unique + `skipDuplicates: true`. + +### What iOS does + +Receive `PERSONAL_RECORD` APNs payload (when the user opts in) + render the `/personal-records` screen via `GET /api/personal-records`. See `06-ios-responsibilities.md`. + +## Domain 5 — GLP-1 PK helper + +### What the server does + +- **One-compartment Bateman absorption / elimination math** — qualitative phase chip (rising/peak/fading), display-only chart with no y-axis labels. Source: `src/lib/medications/glp1-pk.ts`. +- **EMA-approved drug catalog** — 6 drugs with per-drug PK constants from EMA EPAR PDFs (cross-validated with Schneck & Urva 2024 for tirzepatide). Source: `src/lib/medications/glp1-knowledge.ts`. +- **Research Mode disclaimer + acknowledgment** — `RESEARCH_MODE_DISCLAIMER_VERSION = "2026-05-14.1"`. Bumped when the disclaimer wording changes or a new drug joins the catalog. Stored per-user as the acknowledged version. +- **Display-only contract** — Research Mode chart is gated by acknowledgment; Coach (GROUND RULE 10/15) refuses to interpret levels regardless of acknowledgment. +- **Two-compartment math deliberately OUT of scope** — would invite numeric concentration reads = MDR Class I "predict / advise" threshold violation. Deferred to v1.6 + medical-device review. + +### What iOS does + +Read the qualitative phase chip + the unit-less chart via `GET /api/medications/{id}/glp1-timeline` (after Research Mode acknowledgment). Display only — never reason from the values. iOS must surface the same disclaimer before rendering the chart. + +## Domain 6 — GLP-1 titration ladder + +### What the server does + +- Read-only EMA reference for the typical titration schedule per drug (Lilly's "4 weeks per step" for Mounjaro, Novo's "4 weeks per step" for Ozempic/Wegovy, etc.). +- Per-user titration history captured in `Medication.titration` / `MedicationIntakeEvent`. +- EMA reference + history feed the Coach `weeklyContext.glp1` block when applicable. + +### What iOS does + +Read + display the history. Never advise. The Coach is the only surface that names the drug + dose; the iOS medication detail screen shows the user's logged data, not a recommendation. + +## Domain 7 — Withings sync + +### What the server does + +- **Withings OAuth flow** — connect via `/api/withings/connect`, callback at `/api/withings/callback`. +- **Measurement sync** — `sync.ts`, every 60 minutes via pg-boss cron + webhook trigger. +- **Activity v2 sync** — `sync-activity.ts`, cron `0 * * * *` (every hour at :00) + webhook `appli=16`. Fields: steps, distance, calories. +- **Sleep v2 sync** — `sync-sleep.ts`, cron `15 * * * *` (every hour at :15) + webhook `appli=44`. +- **Medication sync** — out of scope for Withings (medications are user-logged or imported, not from Withings). +- **Idempotency** — `(userId, type, measuredAt, source, sleepStage)` composite unique with NULLS NOT DISTINCT (Migration 0055). +- **Day-anchoring** — Withings returns per-day aggregates; the server stores them at the day's noon UTC (12:00:00Z) so the row lands inside the local day for every user timezone in the [-11, +12] range. +- **Cron-fired vs webhook** — webhook is the primary delivery; cron is the safety net for the 1 % of webhook deliveries Withings drops. + +### What iOS does + +Nothing. The iOS app doesn't talk to Withings directly. When the user connects Withings on the web, the server pulls + pushes to iOS via the standard data-flow (Insights regenerate, Health Score updates, APNs notifications). iOS sees the resulting rows via `GET /api/measurements`. + +## Domain 8 — Health Score (separate from analytics generation) + +Covered in Domain 3 above and detailed in `16-health-score-logic.md`. Listed twice because it crosses two domains: math + persistence + cron-refresh all live server-side. + +## Domain 9 — Source-priority two-axis resolver + +### What the server does + +- **Per-metric source priority ladder** — `User.sourcePriorityJson` stores `{ "steps": ["APPLE_HEALTH", "WITHINGS", "MANUAL"], "weight": ["WITHINGS", "APPLE_HEALTH", "MANUAL"], ... }`. +- **Device-type ladder** — `watch > band > ring > phone > scale > other > unknown`. Per-metric overrides supported. +- **Picker algorithm** (`src/lib/analytics/source-priority.ts`): + 1. Bucket measurements by user-tz day. + 2. Walk source ladder, pick first source present in bucket. + 3. Among picked source's rows, walk device-type ladder, keep only top-ranked device-type rows. + 4. Tiebreak preserved by caller's `ORDER BY measuredAt ASC, id ASC`. +- **Output:** filtered row list (subset of input) + per-day picked source for audit/debug overlays. + +### What iOS does + +Nothing. iOS tags rows with source + deviceType when ingesting from HealthKit; the picker decides which contribute to aggregates. iOS does NOT re-resolve. + +## Domain 10 — Audit log + +### What the server does + +- **`AuditLog` table** — every privileged action (login, password change, OAuth connect, device register, settings change, manual measurement edit/delete, GDPR export request, etc.) writes a row. +- **`auditLog()` helper** — `src/lib/auth/audit.ts`. Two-phase write: `create` then `update` to attach error/result. +- **30-day retention** — `audit-log-cleanup.ts` pg-boss job deletes rows older than 30 days. +- **Admin surface** — `/admin/audit-log` page reads + filters. + +### What iOS does + +Nothing. iOS-originated actions land in the audit log via the same API endpoints the web app uses (the audit-log write is server-side). iOS can render the user's own audit log via `GET /api/audit-log` if a "my activity" screen is in scope. + +## Domain 11 — Rate limiter + +### What the server does + +- **Per-user + per-endpoint** Postgres-anchored rate limiter. Source: `src/lib/rate-limit.ts`. +- **Sliding window** counts requests in the last N ms; 429 when over. +- **Per-route caps** — `/api/measurements/batch` is 60/min/user; `/api/insights/generate` is lower (provider call cost). See each route for its specific cap. +- **5-minute cleanup cron** — `*/5 * * * *` purges expired counters. + +### What iOS does + +Handle 429 responses gracefully — display a "too many requests, try again in a moment" toast. Back off exponentially on retry. The iOS sync queue should batch + space its uploads naturally; healthy use should never hit the limit. + +## Domain 12 — i18n + +### What the server does + +- **Flat-file translation bundles** — `messages/de.json`, `en.json`, `fr.json`, `es.json`, `it.json`, `pl.json`. +- **Server-translator** — `src/lib/i18n/server-translator.ts`. Reads + walks the JSON tree by `dotted.key.path`. +- **Locale list** — `["de", "en", "fr", "es", "it", "pl"]`. Source: `src/lib/i18n/config.ts`. +- **Maintained locales** — `{ de, en }` are Marc-maintained. The other four are AI-drafted with structural-coverage tests. + +### What iOS does + +Bundle the JSON files (or hand-port to Swift `.strings` / `.stringsdict` via SwiftGen). Server-side strings (Insight prose, Coach prose) arrive pre-localised in the API response — iOS does NOT translate them again. + +iOS does translate its own UI labels (button text, screen titles, etc.). Source of truth = `messages/.json`; iOS ships the same files. + +## Domain 13 — Notification dispatcher + +### What the server does + +- **Multi-channel dispatcher** — Telegram + ntfy + Web Push + APNs. Source: `src/lib/notifications/dispatcher.ts`. +- **Per-user-per-channel preferences** — `NotificationPreference` table, default ON except `PERSONAL_RECORD` (default OFF). +- **Channel-state machine** — handles transient failures, exponential backoff, give-up after 5 transient failures. +- **APNs sender** — `src/lib/notifications/senders/apns.ts`. Uses `@parse/node-apn` for HTTP/2 connection pool + JWT bearer rotation. One Provider per gateway (sandbox / production). +- **Hard-reject handling** — when APNs returns `Unregistered`, `BadDeviceToken`, or `DeviceTokenNotForTopic`, the device row is deleted from the DB. +- **VAPID config** — Web Push key management. + +### What iOS does + +Receive APNs payloads, parse `eventType` + `deepLink`, route the user to the right screen. Re-register the APNs token on rotation (post to `/api/devices` again). See `06-ios-responsibilities.md`. + +## Domain 14 — Personal Record detection + +Covered in Domain 4 above. Cross-listed because the PR worker is one of the larger server-side jobs the iOS app inherits the result of. + +## Domain 15 — API token store + +### What the server does + +- **`ApiToken` table** — long-lived tokens for native clients + scripted integrations. SHA-256 hashed in DB; plaintext returned once at creation. +- **Per-token scope** — `read`, `write`, `admin`. Most native clients use `read+write`. +- **Per-token revoke** — `DELETE /api/tokens/{id}`. The deletion is propagated to the auth middleware via cache invalidation. +- **Per-token last-used** — updated on every successful auth. + +### What iOS does + +The iOS app does NOT use long-lived API tokens for normal use — it uses the 30-day bearer issued by `/api/auth/login`. Long-lived API tokens are an advanced feature surfaced in Settings → API for users who want to script against their own data; iOS can render the page but doesn't generate tokens for itself. + +## Domain 16 — Onboarding + registration + +### What the server does + +- **Registration** — `/api/auth/register` with email + password. +- **Onboarding flow** — multi-step (`/api/onboarding/*`) collecting timezone, height/age, goals. +- **Passkey enrolment** — `/api/auth/passkey/register-begin` + `register-verify` (WebAuthn). +- **Registration status** — `/api/auth/registration-status` reports whether onboarding is complete. + +### What iOS does + +Render the onboarding screens (Swift native), POST through the endpoints. The server enforces step order + validation. Passkey enrolment uses `ASAuthorization` on iOS — see `05-auth-flows.md`. + +## Domain 17 — Doctor report generation + +### What the server does + +- **PDF doctor report** — `/api/doctor-report/pdf` server-renders a PDF summarising the user's last 90 days. Includes recent measurements, mood trend, medication compliance, PRs, AI Insight. +- **CSV export** — `/api/export/csv` for downstream tooling. +- **GDPR export** — `/api/export/gdpr` ships every row the user owns as JSON + ZIP. + +### What iOS does + +Trigger the export, hand the resulting URL to iOS's share sheet / save-to-Files dialog. The PDF is server-rendered with a stable layout — iOS does not regenerate. + +## Domain 18 — Achievements / gamification + +### What the server does + +- **`UserAchievement` table** — per-user-per-achievement-id rows with unlock timestamp. +- **`/api/gamification/*`** — list achievements, unlock state, streak counters. + +### What iOS does + +Render the achievements page. The unlock rules are server-side; iOS reflects state. + +## Domain 19 — Admin surface + +### What the server does + +- `/admin/*` pages for ops: system status, audit log, host metrics, feature flags, user impersonation. +- `/api/admin/*` API. +- Admin-only routes gated by `User.role`. + +### What iOS does + +The iOS app is NOT an admin client in v1.5. Admin features live in the web app only. + +## Domain 20 — Cron + worker process + +### What the server does + +- **pg-boss** as the queue. Source: `src/lib/jobs/boss-instance.ts`. +- **Worker process** — separate Node process from the Next.js server. Source: `src/lib/jobs/reminder-worker.ts`. +- **Queues** registered: + - `medication-reminder` — every 15 min + - `withings-fallback-sync` — every 60 min + - `withings-activity-sync` — every 60 min at :00 + - `withings-sleep-sync` — every 60 min at :15 + - `pr-detection` — on-demand + 30 min fallback + - `audit-log-cleanup` — daily + - `idempotency-cleanup` — every 5 min + - `feedback-aggregator` — daily + - `host-metric-sampler` — every minute + - `medication-inventory-expire` — daily +- **Cron format** — standard `*/15 * * * *` etc. + +### What iOS does + +Nothing. The worker is server-process; iOS sees the results of cron-fired jobs in API responses + APNs payloads. + +## Domain 21 — Encryption + crypto + +### What the server does + +- **`encrypt()` / `decrypt()`** for sensitive columns (`aiAnthropicKeyEncrypted`, `codexAccessTokenEncrypted`, Telegram bot token, ntfy auth, etc.). Source: `src/lib/crypto.ts`. +- **Argon2id** password hashing. +- **Bearer token signing** with HMAC. + +### What iOS does + +Only Keychain storage on the device — that's a different surface (OS-managed). The bearer token is opaque to iOS; iOS does NOT decode it. + +## Domain 22 — Idempotency middleware + +### What the server does + +- **`withIdempotency()`** wrapper around mutation handlers. Source: `src/lib/idempotency.ts`. +- **Cache** — `(userId, idempotencyKey)` → response body, TTL 24h. +- **5-minute cleanup cron** purges expired rows. + +### What iOS does + +Send `Idempotency-Key` header on every batch ingest / mutation it would replay on network error. UUIDv4 is the standard choice. + +## What the server does NOT do (iOS exclusive) + +The complement: see `06-ios-responsibilities.md` Domain 1-5. + +## Cross-references + +- **06-ios-responsibilities.md** — the inverse. +- **14-coach-mental-model.md** — Coach detail. +- **15-insights-architecture.md** — Insights detail. +- **16-health-score-logic.md** — Health Score detail. +- **08-locked-contracts.md** — exact API surface area. + +## "Since v1.4.24" diff markers + +- **NEW v1.4.25 W14a** — OpenAPI hard-flip; every endpoint exposes a published OpenAPI 3.1 spec. iOS can codegen against `/api/openapi.json`. +- **NEW v1.4.25 W14c** — Native FR/ES/IT/PL Coach system prompts via the safety-contract matrix. +- **NEW v1.4.25 W16c** — PR detection pg-boss queue (concurrency=5 + 30-min fallback + 7-sample warm-up + 6 workout slots). +- **NEW v1.4.25 W17b/c** — Withings Activity v2 + Sleep v2 syncs added (the cron at :00 and :15). +- **NEW v1.4.25 W19a/c** — GLP-1 knowledge layer + PK helper + Research Mode disclaimer-version pinning. +- **NEW v1.4.25 W5d** — Full Withings measurement-type coverage: `FAT_FREE_MASS`, `FAT_MASS`, `MUSCLE_MASS`, `SKIN_TEMPERATURE`, `PULSE_WAVE_VELOCITY`, `VASCULAR_AGE`, `VISCERAL_FAT`. +- **NEW v1.4.25 W8e** — Health Score per-component source attribution + `asOf` deterministic. +- **NEW v1.4.25 W8c** — Two-axis source-priority picker (source × device-type). +- **NEW v1.4.25 W10** — Rate limit on batch ingest 60/min/user. +- **NEW v1.4.25 Fix-G** — `computeHealthScore` purity restored (no `Date.now()` leak). + +## iOS implementation checklist + +1. **Trust the server.** Every domain above is already implemented. +2. **Codegen Swift models** from `/api/openapi.json`. Skip hand-writing. +3. **Read the server's APNs payload schema** before rendering. The `eventType` + `deepLink` keys are the routing inputs. +4. **Treat the server as authoritative.** When iOS and server disagree on any computed value (Health Score, compliance %, PR list), the server wins — iOS re-fetches. +5. **Never recompute server-side logic on-device.** The drift will accumulate; the test surface is server-only. + +## Self-test snippet + +```bash +# Check what the worker process is running +curl -s http://localhost:3000/api/admin/worker-status \ + -H "Authorization: Bearer $ADMIN_TOKEN" | jq + +# Confirm OpenAPI is published +curl -s http://localhost:3000/api/openapi.json | jq '.paths | keys | length' +```