docs(ios-handoff): E-Closeout — recommended flow + research pointers

Two new files closing the v1.4.25 / v1.5 iOS handoff doc-pack.

09-recommended-flow.md (~6000 tokens)
- First-time setup recipe (clone, dev server, API token, Simulator pointing)
- Contract-diff loop (openapi:check + Swift codegen regen)
- Touch-disjoint discipline for parallel iOS sub-agents
- Marc-Voice commit-message style guide with examples + counterexamples
- Marathon-pattern handoff section (non-negotiables, pre-flight rule, research-first)
- Pre-release Quality Gate checklist (8 reviewers + reconcile pass, modelled on W21)
- Mock vs hit-live test strategy (unit / integration / e2e split)
- CI workflow surfaces (server-side vs iOS-side, the cross-side handoff)
- 10 iOS-only gotchas the server log never sees (Simulator HealthKit,
  Withings deep-link return, RESEARCH_MODE_DISCLAIMER_VERSION drift,
  SSE streaming via URLSession.bytes, idempotency-key lifecycle, etc.)

10-research-pointers.md (~3500 tokens)
- Ecosystem research index (Apple Health, GLP-1, wearables comparison, Withings)
- UX + design research index (health-score provenance, insights sub-pages,
  source-priority two-axis, zielwerte redesign)
- Feature-specific research (W8d enums, W14b onboarding, W14c native Coach
  prompts, W16b workouts, W16c PR detection, W17b-c Withings v2)
- W21 review findings index with what's still pending in v1.4.26
- W10 baseline (historical context)
- Pointer to .planning/phase-W21-reconcile-plan.md as the canonical post-W21
  inventory + the 7 Fix-* surface map
- Research-first directive (which feature classes require a research note
  before code lands) + the format convention for new notes
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:42:44 +02:00
1 parent 24d88858e9
commit b4a98ef38e
2 files changed
+991

No files matched your search

@@ -0,0 +1,622 @@
---
file: 09-recommended-flow.md
purpose: The iOS-side workflow recipe — first-time setup, contract-diff loop, parallel-dispatch discipline, pre-release Quality Gate checklist, mock-vs-live test strategy, CI surfaces, and the iOS-only gotchas that the server doesn't see.
when_to_read: Day 1 of iOS work. Re-read before every PR that touches a server-bound contract, before every parallel-dispatch session, and before tagging any v1.5.x release.
prerequisites: 00-philosophy.md, 01-repo-tour.md, 03-api-contracts.md, 08-locked-contracts.md
estimated_tokens: ~6000
version_anchor: v1.4.25 / sha 49f71c92
---
## TL;DR
The iOS app is a thin client of a strict-Zod server, so the day-to-day workflow centres on three loops: a contract-diff loop that catches drift before it lands, a parallel-dispatch loop that lets multiple iOS sub-agents work in touch-disjoint slots without merge pain, and a Quality Gate loop that runs eight reviewers in parallel against `develop` before the release-merge to `main`. Mocks belong in unit tests, testcontainers in integration tests, and a staging Coolify instance in e2e. Everything else here is the why, the recipes, and the gotchas.
## Decision tree — what to do now
| Situation | First action |
| --- | --- |
| Cold-start, never run the dev server | § 1 First-time setup |
| Touching anything server-bound (DTO, endpoint URL, query params) | § 2 Contract-diff loop |
| Spawning more than one iOS sub-agent in parallel | § 3 Touch-disjoint discipline |
| Tagging a v1.5.x release | § 4 Marathon-pattern handoff + § 5 Quality Gate |
| Writing a new test | § 6 Mock vs live |
| Looking at red CI on a PR | § 7 CI workflow + § 8 iOS-only gotchas |
---
## § 1 — First-time setup
### 1.1 Clone + bootstrap
```bash
# from a fresh shell
git clone git@github.com:MarcBombeck/HealthLog.git
cd HealthLog
git checkout develop # daily work goes here, never on main
nvm use 20 # Node 20+ — required by Next.js 16
corepack enable # pnpm 10.31.0 ships via Corepack
pnpm install
pnpm db:generate # regenerate Prisma client
```
### 1.2 Postgres + the dev server
```bash
docker compose up -d postgres # Postgres 16 on 5432
cp .env.example .env.local # then edit DATABASE_URL + SESSION_SECRET
pnpm db:migrate:deploy # apply migrations 0001..0060
pnpm dev # Next.js dev server on :3000 (Turbopack)
```
Health-check the boot:
```bash
curl http://localhost:3000/api/health
# expect: {"data":{"status":"ok","worker":"running",...},"error":null}
```
### 1.3 Issue an API token for the iOS Simulator
The iOS app uses Bearer auth. The fastest way to a working iOS build:
```bash
# 1. Register a dev user (creates the cookie session)
curl -X POST http://localhost:3000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"ios-dev@example.com","username":"ios-dev","password":"correct-horse-battery-staple"}' \
-c cookies.txt
# 2. Mint a wildcard-scope token (web Settings → API tokens does the same)
curl -X POST http://localhost:3000/api/tokens \
-H 'Content-Type: application/json' \
-b cookies.txt \
-d '{"name":"iOS Simulator dev","permissions":["*"],"expiresInDays":365}'
# → returns { data: { token: "hlk_..." } }
```
Drop the `hlk_*` value into the iOS Simulator app's Settings → API base URL + token. Default API base for the Simulator is `http://localhost:3000` — the iOS app's network layer reads it from `UserDefaults` so multi-host operators (self-hosters) can point at their own Coolify instance.
### 1.4 Apple Health permissions in the Simulator
The iOS Simulator **cannot read Apple Health data** — HealthKit on Simulator is a stubbed framework. Two options:
| Need | Path |
| --- | --- |
| Manual ad-hoc data | Tap the manual-entry sheet in the iOS app; it round-trips through `POST /api/measurements` (single) |
| Realistic HealthKit data shapes | Test on a physical iPhone with seeded HealthKit data, OR seed Postgres directly with `POST /api/measurements/batch` against the seeded fixtures |
For batch-shape testing, the canonical fixture lives in `tests/integration/__fixtures__/apple-health-batch.json`. Replay it with:
```bash
curl -X POST http://localhost:3000/api/measurements/batch \
-H "Authorization: Bearer $HLK_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Client-Type: native" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @tests/integration/__fixtures__/apple-health-batch.json
```
### 1.5 STOP HERE if…
| If you only need… | …skip to… |
| --- | --- |
| To call ONE endpoint and verify the wire shape | `03-api-contracts.md` § matching route |
| To wire login + refresh in Swift | `05-auth-flows.md` § 2 |
| To debug a 422 from a batch | `08-locked-contracts.md` § 2 |
---
## § 2 — The contract-diff loop
Every iOS PR that touches an endpoint, a DTO, a query string, or an enum value MUST verify the contract before merge. Drift hides until prod.
### 2.1 The 4-step loop
```
1. git fetch origin && git rebase origin/develop # absorb latest server changes
2. pnpm openapi:check # diff Zod registry vs committed YAML
3. (if 2 fails) pnpm openapi:generate # regenerate the YAML, commit if intentional
4. Regenerate iOS Swift codegen from docs/api/openapi.yaml
```
### 2.2 What `openapi:check` actually does
```typescript
// from scripts/check-openapi.ts:6
// Hard-fails on drift since v1.4.25 — the Zod registry is the
// source of truth for the public API contract that the v1.5 iOS
// Swift codegen consumes.
```
Two outcomes:
| Result | What happened | Action |
| --- | --- | --- |
| Exit 0 | Zod registry == committed YAML | Safe; proceed |
| Exit 1 with diff | A server change drifted the Zod schemas without regenerating | Run `pnpm openapi:generate`, review the diff, commit if the change is intentional. If the diff includes a breaking shape change, raise it before merging |
### 2.3 Swift codegen recipe
```bash
# On the iOS side, mirror the server YAML into Swift
swift run swift-openapi-generator generate \
--output-directory Sources/HealthLogAPI/Generated \
--config openapi-generator-config.yaml \
../HealthLog/docs/api/openapi.yaml
```
Codegen-driven DTOs replace hand-written `Codable` structs. If a field that the iOS UI relies on disappeared from the regenerated source, the build breaks — exactly the failure mode you want.
### 2.4 What to do when the diff is intentional
```
1. Open the server PR that introduced the drift.
2. Confirm the change is additive (server policy: additive across minor versions).
3. If the change is breaking (rare): coordinate the iOS rev-lock with the server tag.
4. Commit the regenerated YAML in the SAME PR that ships the iOS-side change.
```
Marc's rule: never ship an iOS PR that depends on un-merged server work. The server tag lands first; the iOS PR rebases against it.
### 2.5 Where the contract-diff fire alarms go off
| Symptom | Most likely cause | Fix |
| --- | --- | --- |
| iOS 422 with `meta.errorCode: "validation_failed"` on a field your DTO sends | iOS DTO has a key the server's Zod-strict parser rejects | Regenerate Swift codegen; remove the stale key |
| iOS 422 on a field your DTO does NOT send but the server now requires | Server added a required field; codegen catches it | Regenerate; populate the new field |
| iOS 200 with wrong-shaped data | Enum value changed spelling | Regenerate; the Swift enum now reflects new spelling |
| CI red on `openapi-drift` | A server hand-edit bypassed the registry | Server-side fix only — never patch the YAML by hand |
---
## § 3 — Touch-disjoint discipline (parallel iOS sub-agents)
Marc's release pattern depends on dispatching multiple sub-agents in parallel against touch-disjoint surfaces. The iOS workspace inherits the same convention.
### 3.1 The rule
> Two sub-agents working at the same time MUST NOT touch the same file. Period.
If two slots both edit `Sources/HealthLogCoach/CoachView.swift`, you get merge conflicts that compound across the wave. The dispatcher pre-divides surfaces so this is impossible.
### 3.2 Wave decomposition recipe
```
Goal: ship N independent iOS features in one session.
Step 1 — Inventory the work. List every file each feature touches.
Step 2 — Find a partition. Group features so no file appears in two groups.
Step 3 — Dispatch in parallel. Each slot gets one group + a touch list.
Step 4 — Merge order. Slot 1 commits first, slot 2 rebases on slot 1, etc.
```
When a clean partition isn't possible (e.g. two features both touch `AppShell.swift`), serialise those two — one waits.
### 3.3 Atomic-commit rule
Each slot ships **one commit per logical change**, never a "WIP" or a squashed mega-commit. The release surfaces the commits as line-items in the CHANGELOG; one commit per item makes the changelog readable.
| Acceptable | Not acceptable |
| --- | --- |
| `feat(coach): add streaming chat view` | `wip: coach + dashboard + auth` |
| `fix(auth): refresh on 401` | `various fixes` |
| `chore(deps): bump Alamofire to 5.10` | `update deps and refactor` |
### 3.4 Marc-Voice commit-message style guide
| Rule | Example |
| --- | --- |
| English, terse, professional | `fix(measurements): unblock batch ingest on 422` |
| Use Conventional Commits prefixes | `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `perf`, `style`, `build`, `ci` |
| Imperative mood — "add" not "added" | `feat(coach): add streaming SSE parser` |
| Scope in parentheses, lowercase | `feat(insights): render dailyBriefing tile` |
| No AI tells | NEVER `Co-Authored-By: Claude`, NEVER `--no-verify`, NEVER reference "agent / marathon / phase / wave / session" in the message |
| No PII | Marc's name, health figures, BD-Zielbereich values, measurement counts never appear in commit messages |
Counterexamples that would fail a review:
```
# BAD — agent-voice
fix(coach): patch streaming bug (Claude-assisted)
# BAD — AI mention
feat(insights): add briefing tile (generated via Claude Opus)
# BAD — marathon language
feat(auth): wave-3 refresh-token rotation
# BAD — PII leak
fix(targets): restore Marc's 140/90 BD-Zielbereich after migration
# GOOD
feat(auth): rotate refresh tokens on use
feat(coach): add SSE streaming view with provenance disclosure
fix(targets): persist user target range after edit
```
### 3.5 Branch model — `develop` is the daily target
```
feature/* ──┐
├──► develop ──► (release-merge via PR) ──► main ──► tag vX.Y.Z
fix/* ──┘ │
└──► CHANGELOG entry pinned to the tag
```
The iOS app's GHCR-equivalent (App Store TestFlight) builds **from main only**. A dev push to develop never produces a TestFlight build — the GitHub Actions workflow trigger is `tags: 'v*'` on `main`.
---
## § 4 — Marathon-pattern handoff (for v1.5.x release sessions)
Marc-memory has a `release-marathon` skill that codifies the pattern. The iOS workspace inherits the rules.
### 4.1 The non-negotiables
| Rule | Why |
| --- | --- |
| NO `Co-Authored-By: Claude` trailer | Marc-Voice: every artefact reads as Marc's authorship |
| NO `--no-verify` on `git commit` or `git push` | Pre-commit hooks (lint, typecheck, test) gate every commit |
| NO `--no-edit` on `git rebase` | The `--no-edit` flag is not a valid rebase option |
| NO direct push to `main` | `main` only accepts release-merge PRs from `develop` |
| NO tagging by sub-agent | Marc tags after the release-merge PR lands |
| NO destructive git commands (`reset --hard`, `push --force` to main, `branch -D`, `clean -f`) unless Marc explicitly asks | Risk of losing other slots' work |
### 4.2 Pre-flight rule
Before opening any iOS PR in a marathon session, read the handoff doc (this directory) first. The wave-0 read budget is roughly:
- `README.md` (this directory)
- `00-philosophy.md`
- `01-repo-tour.md`
- `08-locked-contracts.md`
- `09-recommended-flow.md` (this file)
Total ~15k tokens. Skipping this surface is the most common cause of merge-blocking review feedback.
### 4.3 Research-first for architecturally-new work
If the iOS feature is genuinely new architecturally (e.g. APNs payload routing, HealthKit observer-query topology, Withings deep-link return), spin up a research note under `.planning/research/` BEFORE writing code. Benchmark Apple Health / Withings / Oura first, propose an ecosystem-fit plan, then implement. Marc's rule from `feedback_research_before_complex_features`.
### 4.4 Phase reports
Every completed phase writes a report at `.planning/phase-W<n>-v1.5.<patch>-<slug>-report.md`. The report captures:
- Phase scope (the task description verbatim)
- What landed (commits + files)
- What deferred (with a one-line "why")
- Test deltas (unit / integration / e2e numbers before vs after)
- Open questions for Marc
These are internal — never published to `docs/` or CHANGELOG.
### 4.5 Decision-relay communication
When a sub-agent hits a decision boundary that needs Marc's input (a UI taste call, an MDR-adjacent feature, a breaking-shape proposal), it pauses and writes a one-paragraph decision-relay note. It does NOT silently pick a direction. Examples that always relay:
- "Should the Coach drawer use a SwiftUI `.sheet` or `.fullScreenCover`?"
- "Should iOS surface the Research Mode chart inline or behind a Settings link?"
- "Should refresh-token reuse trigger a silent re-login or a hard logout?"
---
## § 5 — Pre-release Quality Gate checklist (W21-pattern)
Marc's v1.4.25 W21 ran an 8-reviewer parallel pass before the release-merge. The iOS workspace mirrors it.
### 5.1 The eight reviewers
| Reviewer | What they check | Output |
| --- | --- | --- |
| **code-review** | Bug-spotting, error handling, off-by-ones, dead branches | `phase-W<n>-code-review-findings.md` |
| **security** | Secret leaks, auth boundary, injection vectors, token storage | `phase-W<n>-security-findings.md` |
| **design** | Touch targets ≥ 44×44 pt, contrast WCAG-AA, palette consistency | `phase-W<n>-design-findings.md` |
| **senior-dev** | Architecture, layering, future-proofing, idiomatic Swift | `phase-W<n>-senior-dev-findings.md` |
| **simplifier** | Dead code, redundant abstractions, over-engineered surfaces | `phase-W<n>-simplifier-findings.md` |
| **product-lead** | User-facing acceptance criteria, copy quality, flow polish | `phase-W<n>-product-lead-assessment.md` |
| **i18n-runtime-probe** | Hard-coded strings, locale-bundle parity, missing keys | `phase-W<n>-i18n-runtime-findings.md` |
| **dead-code-scan** | Unused exports, unreferenced files, orphaned migrations | `phase-W<n>-dead-code-findings.md` |
### 5.2 The reconcile pass
After the 8 reviewers land, run a **reconcile** pass that:
1. Collates findings into severity buckets (C / H / M / L)
2. Maps each must-fix finding to a touch-disjoint Fix-* surface
3. Estimates commit count per surface
4. Dispatches fix sub-agents in parallel against those surfaces
The reconcile plan lives at `.planning/phase-W<n>-reconcile-plan.md` and is the single canonical source for "what's still pending before tag". Example: `.planning/phase-W21-reconcile-plan.md` (v1.4.25's W21 reconcile, 63 must-fix findings → 7 Fix-* surfaces).
### 5.3 Severity rubric
| Severity | Rule |
| --- | --- |
| **Critical** | Ship-blocker. Security leak, MDR-line crossing, data loss. Always applied before tag |
| **High** | Apply before tag unless a clean defer-rationale exists |
| **Medium** | Apply if the file is already open in another Fix-* slot (cheap reuse) |
| **Low** | Defer to the next patch unless trivially cheap |
### 5.4 Pre-tag final-pass checklist
Once Fix-* surfaces land:
```
[ ] pnpm typecheck && pnpm lint && pnpm test && pnpm format:check && pnpm build (server)
[ ] pnpm test:integration (server, testcontainers)
[ ] pnpm e2e (server, Playwright)
[ ] pnpm openapi:check (no drift)
[ ] Swift codegen runs cleanly against docs/api/openapi.yaml
[ ] xcodebuild test -scheme HealthLog (iOS unit + UI tests)
[ ] PII-grep against CHANGELOG + release notes: no Marc / 140/90 / BD-Zielbereich
[ ] AI-tell grep: no "Claude / agent / marathon / phase / wave / session" in user-facing artefacts
```
If every box ticks, open the release-merge PR develop → main. Marc reviews + tags.
---
## § 6 — Mock vs hit-live test strategy
Three test layers, three different data realities.
### 6.1 The decision tree
| Need | Layer | Backing |
| --- | --- | --- |
| Test a single function | Unit | Mocked Prisma (`vi.mock("@/lib/db")`) |
| Test a full HTTP path against a real DB | Integration | Testcontainers Postgres (`pnpm test:integration`) |
| Test a user flow across multiple pages | E2E | Playwright against a Coolify staging instance |
| Test the iOS app against the real server | E2E | iOS Simulator pointed at staging URL |
### 6.2 Unit tests — what to mock
```typescript
// from a typical Vitest unit test
import { vi, describe, it, expect } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() } } }));
vi.mock("@/lib/auth/session", () => ({ requireAuth: vi.fn().mockResolvedValue({ user: { id: "u1" } }) }));
```
Rules:
- Mock Prisma; never hit a real DB
- Mock external HTTP (Withings, Anthropic, OpenAI)
- Stub time with `vi.useFakeTimers()` and pin to a specific ISO instant
- Never mock Zod — schemas must validate against real input shape
### 6.3 Integration tests — testcontainers
```bash
pnpm test:integration
# Boots a fresh Postgres 16 container, runs migrations, spawns a test instance, runs every spec, tears down.
```
These tests are the safety net against drift between Prisma client and the migrations. If `pnpm test` passes but `pnpm test:integration` fails, you have a schema-vs-client mismatch — almost always a migration rollback bug.
### 6.4 E2E — staging only
The iOS Simulator's e2e flow points at the Coolify staging URL (separate from production). Staging has:
- A fresh Postgres + restored backup
- Marc's seeded test account
- Real AI providers (Anthropic key in env)
- Real Withings sandbox creds
Never run iOS e2e against production. The smoke difference matters only for the dispatcher cron schedule — staging cron schedules use a `Europe/Berlin` clone but disable the offhost-backup queue (it shouldn't touch S3).
### 6.5 iOS unit + UI tests
```bash
# Unit tests — XCTest, mocked URLSession
xcodebuild test -scheme HealthLog -destination 'platform=iOS Simulator,name=iPhone 15'
# UI tests — XCUITest against the iOS Simulator with a stubbed server
xcodebuild test -scheme HealthLogUITests -destination 'platform=iOS Simulator,name=iPhone 15'
```
Stub the server with a local `URLProtocol` subclass that intercepts every request. No physical server runs during iOS unit tests — the goal is to verify the iOS state machine in isolation.
---
## § 7 — CI workflow surfaces
Two CI surfaces matter: the server's GitHub Actions and the iOS-side GitHub Actions (or Xcode Cloud). They overlap deliberately.
### 7.1 What the server CI runs on every PR
| Workflow | Trigger | What it does |
| --- | --- | --- |
| `e2e.yml` | PR + push | Playwright against ephemeral Postgres |
| `integration.yml` | PR + push | Vitest integration (testcontainers) |
| `security.yml` | PR + daily | `pnpm audit`, secret scanning, OpenAPI drift gate (HARD-FAIL) |
| `docker-publish.yml` | Tag `v*` on `main` only | Multi-arch GHCR image build |
| `post-publish-verify.yml` | After GHCR build | Pulls the new image, runs health probes |
The hard-flip gate to watch: `pnpm openapi:check` inside `security.yml`. If it fails, the PR cannot merge — even with admin override, Marc's policy is to fix the drift instead.
### 7.2 What the iOS CI should run
```
1. xcodebuild test -scheme HealthLog # unit + UI tests
2. xcodebuild archive -scheme HealthLog -configuration Release # ship-able archive
3. Swift codegen drift check (regenerate, diff, fail if differ)
4. SwiftLint --strict
5. (Optional) accessibility audit on UI tests via XCTest
```
iOS CI does NOT need to run the server's tests. The contract is OpenAPI YAML; if YAML and Swift codegen agree, iOS doesn't care how the server tests itself.
### 7.3 The cross-side handoff
```
Server PR → develop → CI green → review → merge
Server tag → main → CI green → GHCR build → Coolify staging
Server tag → main → docs/api/openapi.yaml frozen on the tag
│
▼
iOS PR → develop → CI green (Swift codegen against frozen YAML)
iOS PR → develop → review → merge
iOS tag → main → CI green → TestFlight
```
iOS NEVER cross-references the server's `develop` branch's OpenAPI. iOS targets the latest server tag's YAML, period. This keeps both sides shippable independently.
---
## § 8 — Common iOS-only gotchas
These don't surface in any server log because the server never sees them.
### 8.1 Apple Health permissions on Simulator
```
Symptom : HKHealthStore.requestAuthorization returns immediately, no prompt
Cause : iOS Simulator stubs the HealthKit framework; no real permission flow exists
Fix : Test on a physical device, OR seed Postgres via /api/measurements/batch
```
The iOS app should detect the Simulator and surface a "running on Simulator — manual entry only" banner in dev builds.
### 8.2 Withings web-only OAuth vs iOS deep-link return
```
Symptom : User taps "Connect Withings" → opens SFSafariViewController → never returns to app
Cause : Withings redirect_uri is server-side (https://...example.com/api/withings/callback),
but the iOS app needs a deep-link back into the app after the cookie session
confirms the connect
Fix : Server-side handler at /api/withings/callback ends with a 302 redirect to a custom
URL scheme (e.g. healthlog://withings/connected), which iOS catches via Universal
Links (in production) or a custom URL scheme (in dev). The Apple Health bridge
does NOT have this issue — there's no OAuth involved.
```
Details in `02-server-architecture.md` § Withings integration.
### 8.3 RESEARCH_MODE_DISCLAIMER_VERSION drift on server redeploy
```
Symptom : iOS user previously acknowledged the Research Mode disclaimer; after a server
redeploy, the chart is gated again with a "you must re-acknowledge" dialog
Cause : The constant in src/lib/medications/glp1-pk.ts moved forward (e.g.
"2026-05-14.1" → "2026-05-21.1") because the server team bumped it.
Fix : iOS reads the live currentDisclaimerVersion from GET /api/auth/me/research-mode
on every Coach turn and on every Research Mode chart render. NEVER cache the
version string client-side longer than one session.
```
See `08-locked-contracts.md` § 6.
### 8.4 SSE Coach streaming via URLSession.bytes(for:), not long-polling
```swift
// CORRECT — URLSession.bytes streams as the server emits
let (bytes, response) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
if line.hasPrefix("data: ") {
let frame = String(line.dropFirst(6))
// decode SSE event frame
}
}
// WRONG — would buffer the entire response before any UI update
let (data, response) = try await URLSession.shared.data(for: request)
```
The Coach UX depends on token-by-token reveal. Buffered reads collapse to a "wait 4s, then everything appears at once" experience that defeats the entire point. See `14-coach-mental-model.md` § Evidence-grounding.
### 8.5 The Idempotency-Key lifecycle on iOS
```
Step 1 : Generate UUID().uuidString once per logical write
Step 2 : Persist the key + body to disk
Step 3 : POST with Idempotency-Key header
Step 4 : On 2xx or non-retryable 4xx (400/415/422), drop the key
Step 5 : On 5xx or network failure, retry with the SAME key (24h replay window)
Step 6 : On 401, refresh the token, retry with SAME key
```
The most common iOS bug here: generating a fresh UUID on retry. That produces a duplicate write, not the idempotent replay. See `17-error-handling.md` § 3.
### 8.6 Time zones in measurement timestamps
```
Symptom : An iOS sample at 23:50 in Pacific/Auckland lands in the wrong day on the dashboard
Cause : iOS sent the timestamp as a UTC string, the server day-bucketed it in UTC instead
of the user's TZ
Fix : Send the timestamp as ISO-8601 WITH offset (e.g. "2026-05-14T23:50:00+12:00").
The server's userDayKey() resolver uses User.timezone to bucket, but the offset
on the wire is what locks the wall-clock moment.
```
### 8.7 The `X-Client-Type: native` header is mandatory on login
```
Symptom : iOS login succeeds but no refresh token comes back
Cause : Without X-Client-Type: native (or the UA prefix HealthLog-iOS), the server treats
the call as web and only sets a cookie
Fix : Always send X-Client-Type: native on /api/auth/login and /api/auth/register
```
See `05-auth-flows.md` § 2.
### 8.8 Push notifications — the APNs token lifecycle
```
Step 1 : iOS requests notification permission
Step 2 : iOS receives an APNs device token (32 bytes hex)
Step 3 : iOS POSTs the token to /api/devices with platform: "ios" + apnsEnvironment
("sandbox" for dev builds, "production" for App Store)
Step 4 : Server stores Device.apnsToken with a partial unique index
Step 5 : On every app launch, iOS re-POSTs the token (APNs tokens rotate silently)
Step 6 : On logout, iOS DELETEs the token via /api/devices/[id]
```
The most common bug: forgetting Step 5. APNs rotates tokens silently; stale tokens fail to deliver. The server's dispatcher cleans up tokens that fail twice in a row.
### 8.9 Coolify staging vs production URLs
```
Production : https://healthlog.example.com (Marc's main instance)
Staging : https://staging.healthlog.example.com (refreshed nightly from prod backup)
Local : http://localhost:3000 (dev server)
```
Never run iOS e2e tests against production. Never run integration tests against staging. Local-Postgres-via-testcontainers for integration; staging-only for e2e.
### 8.10 Six locales — string keys must exist in all of them
```
Symptom : iOS app shows the raw key "coach.refusal.glp1_dose" instead of the translation
Cause : The key was added to en.json but not to de.json / fr.json / es.json / it.json / pl.json
Fix : Add the key to all six bundles in the same PR. The server's i18n integrity test
blocks PRs that drop a key; the iOS side has no such gate — review carefully.
```
See `19-i18n-system.md` § 3.
---
## § 9 — Common iOS PR shapes (cheat sheet)
| PR type | Files typically touched | Reviewer focus |
| --- | --- | --- |
| New endpoint client method | `Sources/HealthLogAPI/Generated/*` (regen) + one new `*ViewModel.swift` | Contract-diff loop |
| New screen | One Swift file under `HealthLog/Views/`, one ViewModel, one string bundle update × 6 | Design + i18n |
| New HealthKit metric | `HealthKitClient.swift` + `MeasurementType` mirror + server-side `apple-health-mapping.ts` first | Server PR first, iOS PR second |
| Bug fix | Single file, single test | Single reviewer (code-review) |
| Dependency bump | `Package.swift` + lockfile | senior-dev — check for behaviour changes |
---
## § 10 — STOP HERE if…
| If your task is… | …skip the rest and read… |
| --- | --- |
| Just calling one endpoint | `03-api-contracts.md` § matching route |
| Wiring login + refresh | `05-auth-flows.md` |
| Building a Coach UI | `14-coach-mental-model.md` + `15-insights-architecture.md` |
| Building a dashboard tile | `11-web-ui-tour.md` § Dashboard + `12-design-system.md` |
| Understanding contract-locks before any code change | `08-locked-contracts.md` |
Otherwise: continue to `10-research-pointers.md`.
@@ -0,0 +1,369 @@
---
file: 10-research-pointers.md
purpose: Cross-link map to the research and review notes already in .planning/research/ — the why behind every architecturally-load-bearing decision iOS-Claude will hit.
when_to_read: When the doc-pack says "read the equivalent research note", when designing a feature that touches Apple Health / Withings / GLP-1 / Health Score / source-priority, or before tagging a v1.5.x release.
prerequisites: 00-philosophy.md (the research-first directive)
estimated_tokens: ~3500
version_anchor: v1.4.25 / sha 49f71c92
---
## TL;DR
Marc's rule from `feedback_research_before_complex_features`: research benchmarks the ecosystem before code lands. The artefacts live under `.planning/research/` and are the canonical "why" alongside the doc-pack's "what". Read the matching research note BEFORE building any architecturally-new iOS feature; reach for the W21 review notes BEFORE tagging a release.
## How to use this file
Every entry is `<file>` → `<word count>` → `<status>` → `<one-line summary>` → `<read this if you're about to>`. Status is **current** (still drives v1.4.26+) or **historical** (already absorbed into shipped code — read for context only).
```
PROJECT_ROOT/.planning/research/ ← all paths in this file are relative to here
```
## STOP HERE if…
| If your task is… | …skip the rest and read… |
| --- | --- |
| Just need an API contract | `03-api-contracts.md` |
| Just need a refusal-probe shape | `08-locked-contracts.md` § 7 |
| Just need the Coach mental model | `14-coach-mental-model.md` |
| Need the v1.4.25 reconcile inventory | `.planning/phase-W21-reconcile-plan.md` (see § 4 below) |
Otherwise: continue here.
---
## § 1 — Ecosystem research (Apple Health, wearables, GLP-1)
### `apple-health-ecosystem-scan.md` — 4113 words — current
GitHub topic `apple-health` deep scan: 30 top repos by stars, license + tech + maturity per row. Catalogues the OSS landscape HealthLog could plug into (HealthGPT, react-native-healthkit, apple-health-grafana, healthkit-to-sqlite, SleepChartKit).
Read this if iOS-Claude is about to:
- Pick an iOS HealthKit binding library
- Decide whether a SwiftUI native chart or a Recharts-WebView mirror is the right call
- Evaluate licence compatibility for any OSS Apple Health adapter
### `apple-health-sync-deep-dive.md` — 4968 words — current
iOS sync patterns + clean-room teardowns. The deeper version of the ecosystem scan; covers observer-query topology, HKAnchoredObjectQuery cursoring, background-delivery quirks, and the conflict-reconciliation patterns the open-wearables and healthkit-to-sqlite projects converged on.
Read this if iOS-Claude is about to:
- Wire `HKObserverQuery` + `HKAnchoredObjectQuery` for incremental sync
- Decide between background-delivery and explicit foreground polling
- Reconcile HealthKit's "edited samples are new IDs" model with the server's externalId-based dedup
### `glp1-feature-inspiration.md` — 12071 words — current — **CRITICAL**
The single largest research artefact in the pack. EMA EPAR PDFs (Tirzepatide / Semaglutide / Liraglutide / Dulaglutide / Saxenda / Rybelsus) extracted near-verbatim; ASCPT pop-PK paper (psp4.13099) for the journal-of-record two-compartment math; clean-room teardown of the closed-source `my-glp-shot` app; **EU MDR 2017/745 + MDCG 2021-24 boundary analysis**.
Read this if iOS-Claude is about to:
- Build ANY GLP-1-touching feature (medication list, injection-site map, dose-change history, plateau detection, Research Mode chart)
- Add or modify a GLP-1 drug in the catalog
- Touch the Coach refusal logic around drug levels (GROUND RULE 9/15)
- Surface a "weight projection" or "dose escalation" UI — those CROSS the MDR threshold and are explicitly out of scope
The four do-not-builds (N1–N4 in the research) are the iOS-side's hard line: never project weight loss, never recommend escalation, never check drug-drug interactions, never import code/schema from `my-glp-shot` (no LICENSE = all rights reserved).
### `glp1-injection-tracking.md` — 4488 words — historical
The W4 first-pass research. Predecessor to `glp1-feature-inspiration.md` — covers the foundational landscape (treatmentClass enum proposal, injection-site map design, side-effect taxonomy, dose-change history schema). Mostly absorbed into shipped code by v1.4.25.
Read this if iOS-Claude is about to:
- Trace the historical reasoning behind why `treatmentClass = GLP1` exists as a separate column
- Understand the injection-site rotation algorithm
### `open-wearables-comparison.md` — 4075 words — current
Side-by-side: Apple Health vs Withings vs HealthLog. Covers data-source coverage, sync mechanics, conflict resolution, and the unique design problems each platform solves. The source for the two-axis source-priority decision.
Read this if iOS-Claude is about to:
- Design any UI that surfaces "which source provided this reading"
- Add a new metric type and need to know which sources can supply it
- Build a Settings → Sources screen
### `withings-api-coverage.md` — 4648 words — historical
Earlier Withings API audit (pre-W17). Coverage table of every Withings endpoint with what HealthLog ingested vs what it ignored. Largely absorbed; the W17 follow-up below supersedes it for activity + sleep specifics.
Read this if iOS-Claude is about to:
- Need a historical "what did Marc consider but not ship" answer for Withings
### `withings-plus-comparison.md` — 5306 words — historical
Older competitive intel on Withings+ subscription. Captures the "what does the consumer app surface that HealthLog doesn't" gaps as of the time it was written. Useful as a feature-parity reference but not actionable for v1.5.
Read this if iOS-Claude is about to:
- Pitch a feature parity argument with the Withings+ app
---
## § 2 — UX + design research
### `health-score-provenance-ux.md` — 3467 words — current
The W8e research that shaped the Health Score provenance accordion. Covers the "trust surface" reasoning, the four-component decomposition, and the `aria-labelledby` pairing pattern.
Read this if iOS-Claude is about to:
- Build a Health Score tile on iOS
- Design the per-component provenance disclosure UI
### `insights-sub-pages-ux.md` — 4961 words — current
The W4 Insights mother-page + sub-pages UX research. Apple Health vs Withings vs Oura tile patterns, hero+sub-tile composition, where the daily briefing sits.
Read this if iOS-Claude is about to:
- Build the iOS Insights surface
- Decide between a single scrollable feed and per-metric tabs
- Design the Daily Briefing tile
### `source-priority-two-axis.md` — 2742 words — current
The W8c two-axis source-priority research. Why metric × device-type is the right shape (vs flat ladder), and how the iOS Settings → Sources screen surfaces both axes.
Read this if iOS-Claude is about to:
- Build Settings → Sources on iOS
- Touch the `pickCanonicalSource()` resolver semantics in any way
### `zielwerte-redesign.md` — 5600 words — historical
The older target-zone redesign research. Mostly absorbed; surfaces the BD-Zielbereich UI thinking that ships at `/insights/zielwerte`.
Read this if iOS-Claude is about to:
- Build the iOS target-zone editor
- Understand why "Zielwerte" is a distinct page (vs nested under Settings)
---
## § 3 — Feature-specific research
### `w8d-implementation-outline.md` — 5051 words — current
Apple Health enum extensions spec. The implementation outline for the v1.4.25 W8d additions (AUDIO_EXPOSURE_ENV, AUDIO_EXPOSURE_HEADPHONE, TIME_IN_DAYLIGHT, plus the workout-batch endpoint).
Read this if iOS-Claude is about to:
- Add a new MeasurementType to the iOS side
- Wire workout ingest
### `w14b-onboarding-rebuild.md` — 3177 words — current
The onboarding wizard redesign spec. Four-step state machine, race-safe step advance, the rationale for skipping the localStorage goals shim.
Read this if iOS-Claude is about to:
- Build the iOS onboarding flow
- Touch any `/api/onboarding/*` endpoint
### `w14c-native-coach-prompts.md` — 3616 words — current
Multi-locale Coach prompt strategy. The reasoning behind native FR/ES/IT/PL prompts (vs the pre-v1.4.25 EN-with-language-footer approach), the YAML matrix structure, the parity test design.
Read this if iOS-Claude is about to:
- Modify any Coach prompt or refusal copy
- Understand why the iOS side cannot localise refusal copy itself (it MUST come from the server)
### `w16b-workout-ingest.md` — 2589 words — current
Workout batch ingest research (W16b). Covers HKWorkout shape, the 5 MB ceiling, the `endedAt < startedAt` guard, and the cross-source dedup gap deliberately left for v1.5.
Read this if iOS-Claude is about to:
- Implement workout ingest from HealthKit on iOS
- Hit a 413 from `/api/workouts/batch`
### `w16c-pr-detection.md` — 2501 words — current
PR detection research (W16c). The warm-up gate, the 6 workout slots, the silent-on-historical-backfill rule, the tie-handling logic.
Read this if iOS-Claude is about to:
- Build the PR list screen
- Wire APNs for `PERSONAL_RECORD_ACHIEVED` notifications
### `w17b-c-withings-activity-sleep.md` — 2653 words — current
Withings v2 spec (activity sync + sleep stage segments). Covers the webhook subscription channels, the OAuth scope upgrade banner, and the hourly fallback cron schedule.
Read this if iOS-Claude is about to:
- Surface the Withings scope upgrade banner on iOS
- Wire the Withings reconnect deep-link return
---
## § 4 — Pre-tag W21 review findings (current v1.4.25 state)
The 8-reviewer parallel pass that ran before the v1.4.25 release-merge. Each finding file is the unfiltered output of one reviewer; the consolidated reconcile plan is the canonical "what's still pending in v1.4.26".
### `w21-code-review-findings.md` — 2340 words
Audited: bug-spotting across the v1.4.25 diff. Caught the `endedAt < startedAt` workout-gate gap, the inventory state-machine bypass, and several quiet edge cases.
Still pending in v1.4.26:
- Light-severity items deferred per the rubric (see reconcile plan)
- Refactor recommendations tagged as M-low priority
### `w21-security-findings.md` — 2358 words
Audited: secret leaks, auth boundary, injection vectors. Caught the critical Withings webhook secret-in-logs leak (Fix-J in the reconcile) + the GLP-1 endpoint missing Zod/audit/rate-limit (Fix-K).
Still pending in v1.4.26:
- Medium-severity dependency upgrade recommendations
- Long-term auth-token rotation policy review
### `w21-design-findings.md` — 2023 words
Audited: touch targets ≥ 44×44 px, palette consistency, contrast WCAG-AA. Caught onboarding wizard buttons below floor, range-bar palette drift, PR-badge dark-mode contrast.
Still pending in v1.4.26:
- Low-severity visual polish items (already documented as deferred)
### `w21-senior-dev-findings.md` — 2466 words
Audited: architecture + future-proofing. Flagged the `withRateLimit` positional-args drift (now option-bag), the `safeRequestProp` narrow-catch surface, several abstraction-needed surfaces.
Still pending in v1.4.26:
- Module boundary cleanup recommendations
- Test infrastructure improvements
### `w21-simplifier-findings.md` — 3340 words
Audited: dead code, redundant abstractions. Caught the `goalsStorageKey` orphaned export + the `GoalsChipPicker` localStorage block with no consumer.
Still pending in v1.4.26:
- Lower-priority dead-code removals deferred to next cleanup pass
### `w21-product-lead-assessment.md` — 3394 words
Audited: user-facing acceptance, copy quality, flow polish. Caught medication history page hard-coded "Zurück" back-button (now `t()`-routed), several copy polish items.
Still pending in v1.4.26:
- Copy-tone refinements scoped as Low
- UX flow simplifications scoped as Medium-defer
### `w21-i18n-runtime-findings.md` — 2624 words
Audited: hard-coded strings, locale-bundle parity. Caught the medication history "Zurück" leak + several runtime-translatable-but-static strings.
Still pending in v1.4.26:
- Long-tail copy that survives in admin-only surfaces
- Locale-pluralisation edge cases for non-DE/EN
### `w21-dead-code-findings.md` — 2841 words
Audited: unused exports, unreferenced files. Caught the same `goalsStorageKey` issue (cross-referenced with simplifier), plus older onboarding-spec leftovers.
Still pending in v1.4.26:
- Confirmed-dead items waiting for the v1.4.26 cleanup pass
---
## § 5 — Older review findings (W10 baseline)
Historical context — the pre-v1.4.20 review pass that established the W21-style 8-reviewer pattern.
| File | Words | Audited |
| --- | --- | --- |
| `w10-code-review-findings.md` | 1990 | First-pass bug audit |
| `w10-security-review-findings.md` | 1465 | First-pass security audit |
| `w10-design-review-findings.md` | 1521 | First-pass design audit |
| `w10-senior-dev-findings.md` | 1974 | First-pass architecture audit |
| `w10-simplifier-findings.md` | 1873 | First-pass simplification audit |
| `w10-product-lead-assessment.md` | 2592 | First-pass UX audit |
| `w10-i18n-runtime-gaps.md` | 1949 | i18n runtime probe baseline |
| `w10-dead-code-candidates.md` | 2124 | Dead-code baseline |
| `w10-reconcile-A-design-report.md` | 973 | Reconcile fix-out for design |
| `w10-reconcile-B-architecture-report.md` | 991 | Reconcile fix-out for architecture |
| `w10-reconcile-C-security-auth-report.md` | 715 | Reconcile fix-out for security/auth |
| `w10-reconcile-D-simplifier-report.md` | 479 | Reconcile fix-out for simplifier |
These are historical — every must-fix item from the W10 pass landed across v1.4.20-v1.4.24. Read only when investigating the historical reasoning behind a v1.4.20-v1.4.24 change.
---
## § 6 — The W21 reconcile plan
### `.planning/phase-W21-reconcile-plan.md` — outside `research/`
The canonical post-W21 reconcile inventory + the 7-Fix-* surface map. Collates 103 findings (2 Critical + 20 High + 41 Medium + 40 Low) from the 8 reviewers into 7 touch-disjoint Fix-* surfaces totalling ~22 commits.
The 7 Fix-* surfaces (each touch-disjoint):
| Surface | Scope | Severity |
| --- | --- | --- |
| **Fix-J** | Withings webhook log redaction (`src/lib/logging/`) | Critical |
| **Fix-K** | GLP-1 endpoint hardening + legacy webhook EOL counter | High |
| **Fix-L** | Design polish (touch targets, palette, contrast, dialog footer, native selects, back-button i18n) | High + Medium |
| **Fix-M** | Inventory state-machine + workout schema gate | Medium |
| **Fix-N** | `withRateLimit` option-bag standardisation + helper extraction | Medium |
| **Fix-O** | Audit-log retention + geo enrichment cleanup | Medium |
| **Fix-P** | Dead-code sweep + simplifier follow-ups | Medium |
**iOS-Claude implication**: every item in the reconcile plan that touches a contract is already documented in `08-locked-contracts.md`. The reconcile plan itself is internal-only — never published. Read it BEFORE tagging v1.5.x to understand what v1.4.26 will likely close.
---
## § 7 — Research-first directive (the meta rule)
Marc-memory `feedback_research_before_complex_features`:
> AI / insights / chart work runs through a research agent first. The agent benchmarks Apple Health / Withings / Oura, proposes an ecosystem-fit plan, then the implementation phase starts.
For iOS-Claude this means: before any of these touches code, a `.planning/research/` note must exist:
- New Coach surface or refusal change
- New Insight type or sub-page
- New chart family (sparkline, range bar, scatter, etc.)
- New ingest source (e.g. Garmin, Fitbit, Strava)
- New medical-adjacent feature (anything PK / dose / drug-interaction / projection)
If the relevant note doesn't exist, file one before writing the iOS code. The research-first rule is the single most reliable predictor of which v1.X.Y features ship cleanly and which need an emergency follow-up patch.
---
## § 8 — How to write a new research note
Format convention (mirrors the existing notes):
```markdown
# <Feature> Research — v1.5.X
**Scope.** One sentence on what's in / what's out.
**Read-only.** No code commits, no schema commits.
**Date.** YYYY-MM-DD
**Author.** Marc Bombeck
## TL;DR
<2-3 sentence summary of the recommendation>
## Section 1 — Ecosystem inventory
<table of 10-30 comparable projects with stars / license / tech / one-liner>
## Section 2 — Clinical / regulatory foundation (when relevant)
<EMA EPAR / FDA / WHO / MDR references with direct PDF links>
## Section 3 — App teardowns
<closed-source app analysis, screenshots, feature lists>
## Section 4 — Boundary analysis (when relevant)
<EU MDR / GDPR / accessibility line-not-to-cross>
## Section 5 — Implementation outline
<schema sketches, route sketches, NOT prescriptive code>
## Section 6 — Recommendations
<3-5 numbered recommendations the Marathon can dispatch from>
## Section 7 — Do-not-builds
<explicit list of features that violate boundaries; cite the source>
```
Word-count target: 2000-5000 for a single-feature note, up to 12000 for a multi-feature deep-dive (like `glp1-feature-inspiration.md`).
---
## § 9 — STOP HERE if…
| If your task is… | …skip the rest and read… |
| --- | --- |
| Just need a contract refresher | `08-locked-contracts.md` |
| Just need the Coach pipeline | `14-coach-mental-model.md` |
| Just need to call an endpoint | `03-api-contracts.md` |
| Tagging v1.5.x | `09-recommended-flow.md` § 4 (marathon-pattern handoff) + `09-recommended-flow.md` § 5 (Quality Gate) |
Otherwise: you have read every file in the v1.5 iOS handoff doc-pack. Marc UAT awaits.