docs(ios-handoff): D-AI Layer — iOS and server responsibility boundaries

The remaining two files of D. 06-ios-responsibilities.md enumerates the five
domains the iOS app owns (HealthKit, Keychain, APNs, offline cache, deep
links) plus the MDR-critical 'don't cross this line' warnings.
07-server-responsibilities.md is the inverse — 22 domains the server
already handles, so iOS Claude knows what NOT to reinvent.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:35:22 +02:00
1 parent 39c0242a85
commit 24d88858e9
2 files changed
+762

No files matched your search

@@ -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> = [
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": "<your-generic-device-token>",
"bundleId": "com.healthlog.ios",
"locale": "en",
"appVersion": "1.5.0",
"model": "iPhone15,3",
"apnsToken": "<hex-encoded-apns-token>",
"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/<locale>.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.
@@ -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/<locale>.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:<TYPE>` 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/<locale>.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'
```