mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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'
|
||||
```
|
||||
Reference in new issue
Block a user