← All projects

health · advanced · ~35-45h

Vitals

An offline-first fitness tracker whose hard parts live once in Kotlin and power two native UIs.

You'll build a health & fitness tracker that works fully offline and syncs when it can: the data model, units, validation, local store, and conflict-resolving sync engine all live once in Kotlin Multiplatform's commonMain, while SwiftUI (HealthKit) and Jetpack Compose (Health Connect) stay native for deep device-health access. The whole project argues that shared business logic — not a shared UI — is what KMP is actually for. The sync server is a thin shell — Go or Spring Boot (Kotlin) — around the same merge rule.

backend Godatabase PostgreSQLmobile SwiftUIai Gemini APIcloud Google Cloud
The one idea that makes this project click

Conflict resolution is deterministic and clock-free: every record carries a monotonic version, and last-writer-by-version wins (with a stable content tiebreak on a full tie). Two offline devices that edited the same measurement converge to the identical state on sync — no server timestamp, no lost update.

Production spec — the contract you're building toward

An offline-first KMP tracker whose data model, units, validation, local SQLDelight store, and the version-guarded sync engine live once in commonMain and merge identically on iOS and Android. The wire contract is small — POST /sync/push + GET /sync/pull over a Bearer token — and a thin sync server (Go default or Spring Boot/Kotlin) enforces the same version guard at parity over Postgres.

Read the full production spec →

Why this stack

A health tracker's difficulty is not the screens — it's offline-first storage, deterministic conflict resolution, unit/validation correctness, and a stable data model that two platforms must agree on. Kotlin Multiplatform lets you write that once in commonMain and reuse it from native SwiftUI and Compose, which stay native precisely so they can reach HealthKit and Health Connect directly. The sync server is a thin shell — Go or Spring Boot (Kotlin) — over a lean relational Postgres, because health data demands privacy and integrity, and merged time-series must stay exact whichever language hosts the endpoint.

What you'll be able to do

  • Structure a Kotlin Multiplatform project with commonMain logic and platform-specific actual modules
  • Bridge platform capabilities (secure storage, health APIs) with expect/actual contracts
  • Model and store time-series health samples locally with SQLDelight on top of SQLite
  • Design an offline-first sync engine with a deterministic last-writer-wins-plus-version merge
  • Reconcile conflicts predictably using monotonic versions and per-record updated-at timestamps
  • Validate and normalise units (kg/lb, km/mi, kcal) once, shared across both platforms
  • Implement the same version-guarded sync server in two backends (Go/pgx and Spring Boot/Kotlin)
  • Consume the shared KMP module from native SwiftUI, Jetpack Compose, and Flutter UIs
  • Deploy a Postgres-backed sync endpoint to Cloud Run + Cloud SQL

TechFit — which tools actually suit this build

TechFit — how well each technology suits this project (editorial 1–5).
Technology Fit Role Why
Kotlin spotlight 5/5 Spotlight — shared data model, units, validation, local store, and sync engine in commonMain. One correct copy of the hard logic is reused by both native UIs via expect/actual bridges.
Go 5/5 Default sync server — push/pull delta endpoints over HTTP, backed by Postgres. A small, fast service with first-class pgx tooling and one-binary deploys for the sync rendezvous.
SwiftUI 4/5 Native iOS UI + HealthKit read/write through a Swift actual implementation. Deep Apple health access stays native while consuming the shared KMP logic.
Jetpack Compose 4/5 Native Android UI + Health Connect read/write through a Kotlin actual implementation. Modern declarative Android UI over the same shared store and sync engine.
PostgreSQL 4/5 Server-side system of record for synced, integrity-checked time-series health data. Relational constraints + monotonic versions keep merged health data exact and auditable.
Google Cloud 4/5 Cloud SQL (Postgres) plus a Cloud Run sync endpoint. Managed Postgres and serverless containers host the sync backend with minimal ops.
Gemini API 3/5 Optional — narrative insights summarising weekly trends from already-stored aggregates. A pleasant add-on, but not load-bearing for tracking or sync correctness.
Flutter 3/5 Alternative UI layer — single codebase over the shared API, with native health bridges. Great UI, but you re-bridge native health APIs anyway and can't share the non-UI logic the KMP way.

The build

Your path filters the steps below — pick a backend, a frontend, and any optional modules.

Build it your way — steps below adapt to your choices.

Backend
Frontend
Optional modules — off by default
  • AI Weekly Insights: Summarise the week's health trends into plain-language insights with Gemini.
  • Natural-Language Logging: Type or speak a sentence like 'ran 5k this morning, felt dizzy after, weight 72.3kg' and Gemini parses it into typed, unit-normalized health entries you confirm before saving.

Learn the language itself: Go · Spring Boot (Kotlin)

Pick your backend (Go or Spring Boot/Kotlin) and frontend (SwiftUI, Compose, or Flutter) above — the steps below adapt. But watch the spotlight: the offline-first sync engine, the merge rule, the unit math, and the data model are written once in Kotlin’s commonMain and consumed by every UI — so a fix to conflict resolution lands on iOS and Android in the same commit. The sync server is just the rendezvous; it obeys the same version rule whether you write it in Go or Kotlin. The KMP logic is the lesson; the server language is a swappable shell around it.

Scaffold a Kotlin Multiplatform project

Beginner

Create a KMP project with a shared module plus Android and iOS targets — the one place the hard offline-first logic will live so both phones run the identical code. Use the official Kotlin Multiplatform wizard so the Gradle and Xcode wiring is correct from day one.

New in this step
Kotlin Multiplatform (KMP)

Write logic once in Kotlin and compile it to several targets — here JVM bytecode for Android and a native framework for iOS.

kotlin multiplatform overview track ↗ docs ↗
shared module

The Gradle module whose code is compiled into every target; the data model, store, and sync engine all live in it.

kotlin multiplatform shared module track ↗
commonMain

The platform-agnostic source set inside the shared module — pure Kotlin that every target shares.

kotlin multiplatform source sets track ↗
androidMain / iosMain

Per-platform source sets that supply the implementations a target needs that pure common code cannot express.

kotlin multiplatform source sets track ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
What 'shared' actually means in KMP

A Kotlin Multiplatform project compiles one shared module to multiple targets: JVM bytecode for Android and a native framework for iOS. Code in commonMain is platform-agnostic; androidMain and iosMain hold platform-specific implementations. The UI is not shared here — SwiftUI and Compose remain native. What travels is the logic, which is exactly the part that’s expensive to get right twice. Generate the starter from the official wizard so the Gradle and Xcode wiring is correct from day one.

Create the project
Run these in your terminal / editor
# Use the Kotlin Multiplatform wizard: https://kmp.jetbrains.com/
# Pick: Android + iOS, "Do not share UI" (native UIs).
# Then verify the shared module builds:
./gradlew :shared:assemble
What success looks like

./gradlew :shared:assemble prints BUILD SUCCESSFUL: the Android target compiles on any OS, and on a Mac the iOS framework is produced. This is just the runway — the next few steps make the merge rule real, turning this empty shell into the offline-first engine the whole project is about.

Define the shared domain model in commonMain

Beginner

In commonMain, model the core health records as plain Kotlin data classes — one agreed shape for a health sample so iOS, Android, and the server never disagree about what a “weight” or “step count” is. Add a bumpVersion() helper that encodes the edit rule the whole merge depends on.

New in this step
data class

A class that auto-generates equals/copy/etc. from its fields; one immutable definition becomes the single source of truth for the model.

kotlin data class track ↗ docs ↗
kotlinx.datetime.Instant

A platform-independent point in time, so a timestamp means the same thing on iOS, Android, and the server.

kotlinx-datetime Instant
@Serializable (kotlinx.serialization)

Marks the class so it round-trips to and from JSON on the wire; Instant serialises to an RFC 3339 string.

kotlinx serialization serializable
monotonic version

A per-record counter that only ever increases on edit; later, the higher version wins a conflict, so it must never go backwards.

monotonic version counter conflict resolution
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The data model is the contract — and version is its engine

Both UIs and the server must agree on what a “weight sample” or “step count” is. Defining it once in commonMain as immutable data classes means there is a single source of truth for field names, nullability, and types — no drift between an iOS struct and an Android class. Keep the model framework-free: no HealthKit, no Health Connect, no Android or Foundation types leak in here. Use kotlinx.datetime.Instant for timestamps so time is represented identically on every platform — and identically to what the sync server stores.

The whole merge later hinges on one invariant, so make it explicit now: a new record starts at version = 1; every local edit sets version = old + 1 and updatedAt = now. The bumpVersion() helper is the only place that rule lives, so no screen can forget it — without it, edits stay at version 1 and the server’s WHERE version < EXCLUDED.version guard silently rejects every update after the first.

Shared domain types (commonMain)
Run these in your terminal / editor
// shared/src/commonMain/kotlin/health/Model.kt
package health

import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable

enum class MetricKind { WEIGHT, STEPS, HEART_RATE, ACTIVE_ENERGY, DISTANCE }

enum class MetricUnit { KG, LB, COUNT, BPM, KCAL, KM, MI }

// @Serializable: the SyncApi round-trips Measurement as JSON, so it (and every embedded
// type) must be serializable. The kotlinx.datetime.Instant fields use kotlinx-datetime's
// built-in serializer -> RFC-3339 strings on the wire (the format the SyncApi Detail pins).
@Serializable
data class Measurement(
    val id: String,            // client-generated UUID, stable across sync
    val kind: MetricKind,
    val value: Double,
    val unit: MetricUnit,
    val recordedAt: Instant,   // when the sample happened
    val updatedAt: Instant,    // when this row last changed (drives merge)
    val version: Long,         // monotonic per-record counter (drives merge)
)

// The single edit rule: new record = version 1; every local edit = version+1, updatedAt = now.
fun Measurement.bumpVersion(now: Instant = Clock.System.now()): Measurement =
    copy(version = version + 1, updatedAt = now)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin Multiplatform engineer in this repo.
Context: Fresh KMP project, Android + iOS targets, shared module. kotlinx-datetime is available.
Task: Create the shared domain model in shared/src/commonMain/kotlin/health/Model.kt.
Requirements:
- MetricKind and MetricUnit enums; a Measurement data class with id, kind, value, unit, recordedAt, updatedAt, version.
- Timestamps use kotlinx.datetime.Instant; no platform (HealthKit/Health Connect/Android) types in commonMain.
- All fields immutable (val); id is a client-generated stable UUID string.
- A bumpVersion(now = Clock.System.now()) extension that returns a copy with version+1 and updatedAt = now;
  document that new records start at version 1 and every local edit calls bumpVersion().
Tests / acceptance:
- `./gradlew :shared:assemble` builds for all targets.
- A commonTest asserts bumpVersion() raises version by exactly 1 and sets the passed updatedAt.
Output: a unified diff plus a one-paragraph note on why the model stays framework-free and why version is monotonic.
What success looks like

The commonTest for bumpVersion() goes green: a record at version = 1 becomes version = 2 with updatedAt set to the passed now, and recordedAt/id/kind are unchanged. A fresh record starts at version = 1; the server’s first push has no existing row, so it lands via the INSERT … VALUES branch (the WHERE measurements.version < EXCLUDED.version guard only gates later ON CONFLICT DO UPDATE writes). Every subsequent edit raises the version, so those updates clear the guard.

Normalise and validate units once

Beginner

Write shared functions in commonMain that convert every value to one canonical unit (kg↔lb, km↔mi) and reject impossible ones — so iOS and Android can never disagree about whether a weight is in pounds or kilograms.

New in this step
canonical unit

The one unit each kind is always stored in (kg for weight, km for distance); converting on input means every device stores the same number.

canonical unit normalization
kotlin.Result

A return type holding either a success value or a failure, so validate() reports a bad value without throwing.

kotlin Result type track ↗ docs ↗
extension function

A function added to an existing type (Measurement.canonical()) that reads like a method but lives outside the class.

kotlin extension function track ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Unit bugs are the classic two-platform divergence

If iOS stores pounds and Android stores kilograms, your synced data is silently wrong. Solve it once: pick a canonical unit per MetricKind (kg for weight, km for distance), normalise on the way in, and convert only for display. Validation lives here too — a negative weight or a 400 bpm heart rate is rejected before it ever reaches the store. Because this is commonMain, both UIs and the sync path get identical behaviour for free.

Canonicalise + validate (commonMain)
Run these in your terminal / editor
// shared/src/commonMain/kotlin/health/Units.kt
package health

private const val LB_PER_KG = 2.2046226218

fun Measurement.canonical(): Measurement = when (unit) {
    MetricUnit.LB -> copy(value = value / LB_PER_KG, unit = MetricUnit.KG)
    MetricUnit.MI -> copy(value = value * 1.609344, unit = MetricUnit.KM)
    else -> this
}

fun Measurement.validate(): Result<Measurement> {
    val unitOk = when (kind) {
        MetricKind.WEIGHT -> unit == MetricUnit.KG || unit == MetricUnit.LB
        MetricKind.STEPS -> unit == MetricUnit.COUNT
        MetricKind.HEART_RATE -> unit == MetricUnit.BPM
        MetricKind.ACTIVE_ENERGY -> unit == MetricUnit.KCAL
        MetricKind.DISTANCE -> unit == MetricUnit.KM || unit == MetricUnit.MI
    }
    if (!unitOk) return Result.failure(IllegalArgumentException("invalid unit $unit for $kind"))

    val valueOk = when (kind) {
        MetricKind.WEIGHT -> value in 1.0..635.0          // kg, sane human range
        MetricKind.HEART_RATE -> value in 20.0..250.0      // bpm
        else -> value >= 0.0
    }
    return if (valueOk) Result.success(this)
    else Result.failure(IllegalArgumentException("value $value out of range for $kind"))
}
Chat prompt — paste into a chat to get the code

A WEIGHT measurement arrives with unit BPM, and a separate one reads -5 kg. Before you read the code: what does validate() return for each, and what exact message does each failure carry?

For a plain chat. It returns complete code; you paste it in yourself.
Role: Kotlin teacher. The reader has no repo access here — return complete code.
Task: Show a commonMain Units.kt with canonical(value/unit) conversion and validate() for a health Measurement.
Requirements:
- Canonical units: kg for weight, km for distance; convert LB->KG and MI->KM with exact factors (2.2046226218, 1.609344).
- validate() returns kotlin.Result; reject kind<->unit mismatches (e.g. WEIGHT with BPM); reject weight outside 1..635 kg and heart rate outside 20..250 bpm; others must be >= 0.
- Pure functions, no platform types, no I/O.
Tests / acceptance (describe, since no repo):
- 200 lb canonicalises to ~90.72 kg.
- A -5 kg weight fails validate(); a 70 kg weight succeeds.
- A WEIGHT measurement with BPM units fails validate().
Output: the complete Units.kt file, no commentary.
What success looks like

200 lb canonicalises to ~90.72 kg (unit becomes KG), and 5 mi to ~8.05 km. Validation rejects a bad kind↔unit pair and an out-of-range value with the exact failure:

WEIGHT + BPM   -> Result.failure: invalid unit BPM for WEIGHT
-5 kg          -> Result.failure: value -5.0 out of range for WEIGHT
70 kg          -> Result.success

Build the full local store with SQLDelight

Intermediate

Add SQLDelight and build the complete MeasurementStore the sync engine will consume — the device’s own durable store so the app works fully offline. Define the measurement table, a one-row sync_state cursor table, and queries for every method the Syncer calls.

New in this step
offline-first

The device is the primary store, not a cache — every read and write works with the network off, and the server is just a later rendezvous.

offline-first architecture
SQLDelight

Generates type-safe Kotlin from SQL files and runs on SQLite, so the same queries work from commonMain on both platforms.

sqldelight kotlin multiplatform
.sq file

A file of plain SQL (table + named queries) that SQLDelight turns into compile-checked Kotlin functions.

sqldelight sq file syntax
dirty flag

A per-row marker meaning “edited locally, not yet pushed”; the Syncer pushes only dirty rows, then clears the flag.

offline sync dirty flag
sync cursor

The last server sequence number this device pulled; storing it lets each pull fetch only what changed since then.

sync cursor incremental pull
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Build the whole store now, so the ★ step is only about the merge

Offline-first means the device is the primary store, not a cache. SQLDelight generates type-safe Kotlin from .sq SQL files and runs on SQLite (present on iOS and Android), so the same queries work from commonMain.

The ★ sync engine two steps from now calls six store methods and persists a cursor, so build all of that here — the spotlight step should be about the merge rule, not about inventing infrastructure mid-stream. The Syncer needs upsert (local edit → dirty = 1), upsertClean (merged-from-server → dirty = 0), byId, dirty(), clearDirty(ids), plus cursor()/setCursor(). The cursor lives in a tiny one-row sync_state table (seeded to 0) so “where’s the cursor stored?” has a concrete answer. Note the column is value_ (trailing underscore dodges a SQL keyword); the domain field, the wire, and the server all use value — same field, different name, not a typo.

One sharp edge in the generated query: clearDirty is UPDATE measurement SET dirty = 0 WHERE id IN ?, and SQLDelight cannot bind an empty collection to IN ? — it expands to IN (), a SQLite syntax error. Because Syncer.sync() calls clearDirty(acceptedIds) and acceptedIds is empty on the common “nothing dirty / all pushes stale” path, the Kotlin clearDirty(ids) wrapper MUST early-return when ids is empty, before it touches the generated query.

Measurement.sq (SQLDelight)
Run these in your terminal / editor
-- shared/src/commonMain/sqldelight/health/Measurement.sq
CREATE TABLE measurement (
  id          TEXT    NOT NULL PRIMARY KEY,
  kind        TEXT    NOT NULL,
  value_      REAL    NOT NULL,         -- domain/wire/server field is `value`
  unit        TEXT    NOT NULL,
  recorded_at INTEGER NOT NULL,         -- epoch millis
  updated_at  INTEGER NOT NULL,         -- epoch millis, drives merge
  version     INTEGER NOT NULL,         -- monotonic per record
  dirty       INTEGER NOT NULL DEFAULT 1  -- 1 = not yet pushed to server
);

CREATE INDEX measurement_dirty ON measurement(dirty);

-- Local edit: mark dirty so the Syncer pushes it.
upsert:
INSERT OR REPLACE INTO measurement(id, kind, value_, unit, recorded_at, updated_at, version, dirty)
VALUES (?, ?, ?, ?, ?, ?, ?, 1);

-- Merged from server: store WITHOUT re-marking dirty.
upsertClean:
INSERT OR REPLACE INTO measurement(id, kind, value_, unit, recorded_at, updated_at, version, dirty)
VALUES (?, ?, ?, ?, ?, ?, ?, 0);

selectById:
SELECT * FROM measurement WHERE id = ?;

selectByKind:
SELECT * FROM measurement WHERE kind = ? ORDER BY recorded_at DESC;

selectDirty:
SELECT * FROM measurement WHERE dirty = 1;

clearDirty:
UPDATE measurement SET dirty = 0 WHERE id IN ?;
SyncState.sq — the on-device cursor (one row)
Run these in your terminal / editor
-- shared/src/commonMain/sqldelight/health/SyncState.sq
CREATE TABLE sync_state (
  id     INTEGER NOT NULL PRIMARY KEY DEFAULT 0 CHECK (id = 0),  -- exactly one row
  cursor INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO sync_state(id, cursor) VALUES (0, 0);      -- seed the single row

selectCursor:
SELECT cursor FROM sync_state WHERE id = 0;

setCursor:
UPDATE sync_state SET cursor = ? WHERE id = 0;
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo.
Context: shared module, SQLDelight Gradle plugin applied, SqlDriver provided per platform (AndroidSqliteDriver, NativeSqliteDriver). Domain Measurement exists. The ★ Syncer will consume this store.
Task: Add Measurement.sq + SyncState.sq and a MeasurementStore in commonMain wrapping the generated queries — the COMPLETE API the Syncer needs.
Requirements:
- measurement table (id PK, value_ REAL, recorded_at/updated_at epoch millis, version, dirty default 1, dirty index).
- sync_state: a one-row (id = 0) cursor table, seeded to 0.
- MeasurementStore exposes ALL of: upsert(m) [dirty=1], upsertClean(m) [dirty=0], byId(id): Measurement?,
  allByKind(kind): List<Measurement>, dirty(): List<Measurement>, clearDirty(ids: List<String>),
  cursor(): Long, setCursor(value: Long).
- Map the generated row <-> domain Measurement (Instant <-> epoch millis) in ONE place; clearDirty([]) is a safe no-op.
Tests / acceptance:
- commonTest (in-memory driver): upsert then byId returns the row unchanged to the millisecond; the row is dirty.
- upsertClean stores a row with dirty = 0; dirty() excludes it; clearDirty([id]) clears a dirty row.
- cursor() returns 0 on a fresh DB; setCursor(42) then cursor() returns 42.
Output: a unified diff plus a note on where the Instant<->millis mapping lives and how the cursor is persisted.
What success looks like

Against an in-memory driver the commonTest passes: upsert(m) then byId(m.id) returns the row unchanged to the millisecond and it appears in dirty(); upsertClean stores a row that dirty() excludes, and clearDirty([id]) flips a dirty row to clean. The cursor persists in sync_state: cursor() returns 0 on a fresh DB, and after setCursor(42) it returns 42.

Bridge secure storage with expect/actual

Intermediate

Declare an expect secure-storage class in commonMain for the auth/sync token, then provide the actual on each platform — so shared code can store a secret safely without ever importing a platform API. Back it with the Keychain on iOS and EncryptedSharedPreferences on Android.

New in this step
expect / actual

commonMain declares a capability with expect; each platform supplies the matching actual — the seam that keeps shared code free of platform APIs.

kotlin multiplatform expect actual track ↗ docs ↗
Keychain (iOS)

Apple’s encrypted, OS-managed store for small secrets like tokens; the iOS actual writes the Bearer token here.

ios keychain secure storage swift track ↗
EncryptedSharedPreferences (Android)

Android’s key/value store with the values encrypted at rest; the Android actual writes the Bearer token here.

android encryptedsharedpreferences security-crypto track ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
expect/actual is how shared code reaches platform secrets

Some things genuinely cannot be shared: storing a token securely needs the iOS Keychain or Android’s encrypted preferences. KMP’s expect/actual lets commonMain declare a capability it needs and each platform provide it. The shared sync engine depends only on the expect SecureStore interface, so it never imports a platform API directly — that’s the seam that keeps the shared layer pure while still using native secure storage.

expect (common) + actual (per platform)
Run these in your terminal / editor
// commonMain — the contract the shared code depends on
expect class SecureStore() {
    fun put(key: String, value: String)
    fun get(key: String): String?
}

// androidMain — backed by EncryptedSharedPreferences (androidx.security:security-crypto)
// iosMain — backed by the Keychain (Security framework via platform.Security.*)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo.
Context: shared module with androidMain and iosMain source sets. Android has androidx.security:security-crypto available.
Task: Implement an expect/actual SecureStore for the sync token.
Requirements:
- commonMain declares `expect class SecureStore` with put(key,value) and get(key): String?.
- androidMain actual uses EncryptedSharedPreferences (AES256_GCM); requires a Context passed at construction.
- iosMain actual uses the Keychain (SecItemAdd/SecItemCopyMatching) keyed by a service + account.
- No platform types appear in commonMain; the sync engine depends only on the expect class.
Tests / acceptance:
- `./gradlew :shared:assemble` builds for Android and iOS targets.
- Android instrumented test: put then get returns the stored value; get for a missing key returns null.
Output: a unified diff plus a one-paragraph explanation of the expect/actual seam.
What success looks like

The Android instrumented test passes: put(key, value) then get(key) round-trips the stored value, and get for a missing key returns null.

Bridge HealthKit and Health Connect with expect/actual

Intermediate

Declare an expect HealthBridge in commonMain that reads recent samples, then implement it natively on each platform — keeping the deep health-API integrations native while the shared engine sees only a clean contract. Use HealthKit on iOS and Health Connect on Android.

New in this step
HealthKit (iOS)

Apple’s framework for reading and writing health samples via HKHealthStore; the iOS actual queries it and maps samples to the shared model.

apple healthkit hkhealthstore track ↗
Health Connect (Android)

Android’s on-device health data store, reached via HealthConnectClient; the Android actual reads typed records from it.

android health connect client
permissions rationale activity

A screen Health Connect requires you to register; without it the system rejects your permission requests silently.

health connect permissions rationale activity
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Native where it pays: deep health-API access

Reading steps or heart rate means talking to HealthKit (HKHealthStore) on iOS and Health Connect (HealthConnectClient) on Android — APIs with permission prompts, background delivery, and platform-specific types. Those stay native. The shared engine only needs a clean expect HealthBridge that returns List<Measurement> in canonical units; each platform’s actual maps its native sample type into the shared model. This is the second half of the lesson: share the logic, keep the device integrations native and thin.

Both platforms have real ceremony before you can read a single sample. On iOS, you must enable the HealthKit capability in Xcode, add the NSHealthShareUsageDescription and NSHealthUpdateUsageDescription keys to Info.plist, and query via HKHealthStore. On Android, you must declare typed permissions in the manifest, register a permissions rationale activity (Health Connect requires it or permission requests fail silently), and query via HealthConnectClient.

The health bridge contract (commonMain)
Run these in your terminal / editor
// commonMain
expect class HealthBridge {
    // Returns canonical-unit Measurements since `sinceEpochMillis`.
    suspend fun read(kind: MetricKind, sinceEpochMillis: Long): List<Measurement>
    suspend fun requestPermissions(kinds: Set<MetricKind>): Boolean
}
iOS HealthKit Info.plist wiring
Run these in your terminal / editor
<!-- iosApp/iosApp/Info.plist -->
<!-- You must also enable the HealthKit capability in the Xcode project target -->
<key>NSHealthShareUsageDescription</key>
<string>We need access to read your health data to sync it.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>We need access to write health data from the app.</string>
Android Health Connect manifest wiring (AndroidManifest.xml)
Run these in your terminal / editor
<!-- Dependency: androidx.health.connect:connect-client -->
<!-- 1) Declare each typed permission you read. -->
<uses-permission android:name="android.permission.health.READ_STEPS"/>
<uses-permission android:name="android.permission.health.READ_HEART_RATE"/>
<uses-permission android:name="android.permission.health.READ_WEIGHT"/>

<application>
  <!-- 2a) Android 14+ : the system opens this to show your rationale. -->
  <activity-alias
      android:name="ViewPermissionUsageActivity"
      android:exported="true"
      android:targetActivity=".PermissionsRationaleActivity"
      android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
    <intent-filter>
      <action android:name="android.intent.action.VIEW_PERMISSION_USAGE"/>
      <category android:name="android.intent.category.HEALTH_PERMISSIONS"/>
    </intent-filter>
  </activity-alias>

  <!-- 2b) Android 13 and below : same rationale, different action. -->
  <activity android:name=".PermissionsRationaleActivity" android:exported="true">
    <intent-filter>
      <action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"/>
    </intent-filter>
  </activity>
</application>
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo, comfortable with HealthKit and Health Connect.
Context: shared module, androidMain + iosMain. Domain Measurement and canonical() exist. Both iOS HealthKit
and Android Health Connect must be wired fully here.
Task: Implement expect/actual HealthBridge with read(kind, since) and requestPermissions(kinds), plus the
iOS Info.plist and Android manifest + rationale wiring.
Requirements:
- commonMain declares expect class HealthBridge as shown.
- iosMain actual uses HealthKit (HKHealthStore, HKSampleQuery); map HKQuantitySample -> Measurement, canonicalised. Add the NSHealthShareUsageDescription and NSHealthUpdateUsageDescription keys to Info.plist.
- androidMain actual uses Health Connect: depend on androidx.health.connect:connect-client; use HealthConnectClient.readRecords;
  map StepsRecord/HeartRateRecord/WeightRecord -> Measurement, canonicalised.
- Android manifest: declare android.permission.health.READ_* for each kind; add a PermissionsRationaleActivity
  registered for BOTH the Android 14+ ViewPermissionUsageActivity alias (VIEW_PERMISSION_USAGE +
  category.HEALTH_PERMISSIONS, permission START_VIEW_PERMISSION_USAGE) and the <=13 ACTION_SHOW_PERMISSIONS_RATIONALE.
- requestPermissions uses PermissionController.createRequestPermissionResultContract and returns true only when
  all requested typed permissions are granted; map MetricKind -> permission/record type explicitly; throw a typed error for unsupported kinds.
Tests / acceptance:
- `./gradlew :shared:assemble` builds both targets.
- Each actual has a unit test (faked platform client) asserting a sample maps to a canonical-unit Measurement.
- An Android lint/manual check confirms the rationale activity is registered (Health Connect rejects apps without it).
Output: a unified diff plus the MetricKind -> (HealthKit type / Health Connect permission+record) mapping table.
What success looks like

./gradlew :shared:assemble builds both targets. On Android the permission dialog appears (Health Connect silently rejects an unregistered rationale activity, so this proves the manifest wiring is in place); a faked-client unit test maps one native sample to a canonical-unit Measurement.

Define the SyncApi and implement it with a Ktor client

Intermediate

Declare the SyncApi interface the engine talks to, then implement it with a Ktor HttpClient — so the Syncer depends on a small, fakeable contract and never knows about HTTP. Point the client at your server’s base URL and attach the Bearer token from SecureStore.

New in this step
Ktor client

A Kotlin HTTP client that runs from commonMain, so both platforms make the same calls with one configuration.

ktor http client kotlin
Bearer token

A secret string sent as Authorization: Bearer <token>; the server looks it up to identify the user, so no request body carries an id.

http authorization bearer token
RFC 3339 timestamps

The text date format (2026-06-20T07:30:00Z) used on the wire, the one representation the client, Go, Spring, and Postgres all agree on.

rfc 3339 iso 8601 timestamp
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The wire client the engine depends on — built before the engine

The Syncer should know nothing about HTTP — it depends only on a small SyncApi interface, so it’s trivially faked in tests. Build that interface and a real KtorSyncApi now so the ★ step has a concrete client to compose, not a hand-wave. The client reads the token you stored in SecureStore and sends it as Authorization: Bearer <token>; timestamps go on the wire as RFC 3339 strings (kotlinx-serialization’s Instant default), which is exactly what the Go/Spring server and Postgres TIMESTAMPTZ agree on — millis is an on-device storage detail only. value/unit stay canonical on the wire.

SyncApi + KtorSyncApi (commonMain)
Run these in your terminal / editor
// shared/src/commonMain/kotlin/health/SyncApi.kt
package health

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable

@Serializable data class PushBody(val records: List<Measurement>)   // typed request envelope
@Serializable data class Pushed(val acceptedIds: List<String>)
@Serializable data class Pulled(val records: List<Measurement>, val nextCursor: Long)

interface SyncApi {
    suspend fun push(records: List<Measurement>): Pushed
    suspend fun pull(cursor: Long): Pulled
}

class KtorSyncApi(
    private val http: HttpClient,
    private val baseUrl: String,
    private val token: () -> String?,   // reads SecureStore.get("sync_token")
) : SyncApi {
    override suspend fun push(records: List<Measurement>): Pushed =
        http.post("$baseUrl/sync/push") {
            bearerAuth(token() ?: error("no sync token"))
            contentType(ContentType.Application.Json)
            setBody(PushBody(records))   // typed envelope -> guarantees the {"records":[...]} wire shape
        }.body()

    override suspend fun pull(cursor: Long): Pulled =
        http.get("$baseUrl/sync/pull") {
            bearerAuth(token() ?: error("no sync token"))
            parameter("cursor", cursor)
        }.body()
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo.
Context: commonMain has Measurement; SecureStore holds the Bearer token under key "sync_token". Ktor client +
ktor content-negotiation (kotlinx-serialization JSON) are on the classpath. The ★ Syncer will depend on SyncApi.
Task: Add SyncApi (interface), the Pushed/Pulled DTOs, and KtorSyncApi in commonMain.
Requirements:
- push(records) POSTs {"records":[Measurement...]} to $baseUrl/sync/push; returns {"acceptedIds":[...]}.
- pull(cursor) GETs $baseUrl/sync/pull?cursor=N; returns {"records":[...], "nextCursor":M}.
- Both attach Authorization: Bearer <token from SecureStore>; serialize Instant as RFC 3339 (kotlinx default);
  value/unit stay canonical. No platform types in commonMain.
- Configure the HttpClient with JSON content negotiation; the base URL is injected (local dev vs deployed).
Tests / acceptance:
- commonTest with Ktor MockEngine: push sends the records and parses acceptedIds; pull parses records + nextCursor.
- A request without a token errors before any network call.
- `./gradlew :shared:allTests` passes.
Output: a unified diff plus the JSON of one push request body (showing RFC 3339 timestamps).
What success looks like

The commonTest with Ktor MockEngine passes: push(records) sends {"records":[…]} with RFC 3339 timestamps and parses acceptedIds; pull(7) requests /sync/pull?cursor=7 and parses records + nextCursor. A call made while SecureStore.get("sync_token") is null throws (error("no sync token")) before any network round-trip — the request never leaves the device.

★ Build the offline-first sync engine in commonMain

Advanced

Write the shared sync engine that pushes locally-changed (dirty) measurements, pulls remote changes, and merges them deterministically — the one place the conflict rule lives, so iOS and Android resolve every clash identically. Write it all in commonMain.

New in this step
push / pull

Push sends this device’s dirty rows to the server; pull asks for everything changed since the stored cursor — together they reconcile the two sides.

offline sync push pull delta
last-writer-wins by version

On a conflict the higher version wins (then later updatedAt, then a stable tiebreak), so the outcome is decided by data, not by a clock or arrival order.

last writer wins conflict resolution version
total and commutative merge

merge is defined for every input pair (total) and merge(a,b) == merge(b,a) (commutative), which is exactly why two devices converge to one value.

commutative merge function convergence
idempotent self-echo

Pull returns the rows you just pushed; re-storing them is a harmless no-op, so running sync() twice changes nothing.

idempotent sync pull echo
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The merge rule: one comparison, the same answer from either side

The one idea here: on conflict, higher version wins; equal version → later updatedAt wins; and on a full tie a stable content compare decides — so merge(a, b) and merge(b, a) always return the same record. That last tiebreak matters: without it, an equal-version-equal-updatedAt tie would keep “whatever I called local”, so device A and device B would disagree on a tie and silently diverge — exactly the bug the convergence test claims can’t happen. Comparing a stable serialized form of the candidates makes the result independent of argument order.

Why this is the spotlight

This is why the project is Kotlin/KMP. The hard part of offline-first is not networking — it’s deciding what happens when the same record changed in two places, and writing that decision once. Push sends dirty rows; pull asks for “everything changed since my last cursor”. Because the merge lives in commonMain, iOS and Android merge identically — a class of “works on Android, corrupts on iOS” bugs simply cannot exist. SwiftUI, Compose, and Flutter all call the same Syncer.sync(), and the sync server (Go or Spring) enforces the same version rule on the wire.

The push-then-pull self-echo is idempotent, not a bug

Because sync() pushes dirty records and then pulls everything changed since the last cursor, the server will return the exact records you just pushed. This “self-echo” is expected and harmless, and clearDirty(acceptedIds) already ran before the pull, so the echo lands on an already-clean row. Two cases: an accepted row is clean before its echo returns, and upsertClean overwrites it with identical data — a no-op. A rejected (stale) local edit — one the server’s version guard skipped, so its id is absent from acceptedIds and it stays dirty — is replaced on pull by the server’s higher-version winner via upsertClean. That is correct last-writer-by-version resolution, not a lost update: your stale edit lost the version race. The self-echo guarantees you never miss a concurrent server write that landed split-second before your pull.

The merge + sync loop (commonMain)
Run these in your terminal / editor
// shared/src/commonMain/kotlin/health/Syncer.kt
package health

// Deterministic, COMMUTATIVE conflict resolution:
//   higher version wins; equal version -> later updatedAt; full tie -> stable content compare.
// The final compare makes merge(a,b) == merge(b,a) even when version AND updatedAt are equal.
private fun Measurement.tiebreak(): String =
    "$version|${updatedAt.toEpochMilliseconds()}|$value|$unit|$kind|$recordedAt"

internal fun merge(local: Measurement, remote: Measurement): Measurement = when {
    remote.version > local.version -> remote
    local.version > remote.version -> local
    remote.updatedAt > local.updatedAt -> remote
    local.updatedAt > remote.updatedAt -> local
    // Full tie: pick by a stable, order-independent content compare (ties are usually byte-identical anyway).
    remote.tiebreak() >= local.tiebreak() -> remote
    else -> local
}

class Syncer(
    private val store: MeasurementStore,
    private val api: SyncApi,        // built in the previous step (KtorSyncApi over an HttpClient)
) {
    suspend fun sync(): SyncResult {
        val pushed = api.push(store.dirty())          // send local changes
        store.clearDirty(pushed.acceptedIds)

        val incoming = api.pull(store.cursor())        // remote changes since last cursor
        for (remote in incoming.records) {
            val local = store.byId(remote.id)
            val winner = if (local == null) remote else merge(local, remote)
            store.upsertClean(winner)                  // store without re-marking dirty
        }
        store.setCursor(incoming.nextCursor)
        return SyncResult(pushed = pushed.acceptedIds.size, pulled = incoming.records.size)
    }
}
Agent prompt — paste into an agent with repo access

Two devices edit the same measurement offline — device A bumps it to version 3, device B to version 2. After both sync, which value wins, and what version does each device end up holding for that row?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo. This is the load-bearing milestone.
Context: commonMain already has the FULL MeasurementStore (upsert/upsertClean/byId/allByKind/dirty/clearDirty/
cursor/setCursor) and the SyncApi interface + KtorSyncApi from the two previous steps. Build only the merge + loop.
Task: Implement Syncer.sync() and the pure merge(local, remote) function in commonMain.
Requirements:
- merge: higher version wins; equal version -> later updatedAt; on a FULL tie (equal version AND updatedAt)
  decide by a stable, order-independent content compare so merge(a,b) == merge(b,a). Total; never throws.
- sync(): push dirty rows, clearDirty(acceptedIds), pull since cursor, merge each (insert remote-only as-is),
  upsertClean the winner, setCursor(nextCursor).
- No platform types in commonMain; all suspend functions; no UI concerns here.
Tests / acceptance:
- commonTest: merge is total AND commutative over all (version, updatedAt) orderings INCLUDING the full tie —
  table-driven; assert merge(a,b) == merge(b,a) for every pair (this is the case the old `else -> local` failed).
- commonTest with a fake SyncApi + in-memory store: two devices editing the same id converge to ONE record after
  sync, and A-then-B equals B-then-A.
- `./gradlew :shared:allTests` passes.
Output: a unified diff plus a 1-paragraph proof that the merge is commutative on the tie case.
What success looks like

merge is total and order-independent: device A’s version = 3 edit beats device B’s version = 2 for the same id, so after both sync each device holds the same row — A’s value at version = 3. Swapping the sync order yields the identical winner, because merge(a, b) == merge(b, a):

id=f1c2…  value=72.3  version=3   // both devices, after sync

Test the merge and sync convergence in commonTest

Advanced

Write shared commonTest cases that prove the merge is deterministic and that two diverging devices converge to one record — tests that run on every target, so passing them means the exact code iOS and Android ship is correct.

New in this step
commonTest

The shared test source set; its tests compile and run on every target, so a green run covers the JVM and iOS code paths at once.

kotlin multiplatform commonTest source set track ↗
fake (test double)

A lightweight stand-in for a real dependency (here a SyncApi) so the test exercises the merge without a network or server.

fake test double vs mock
in-memory store

A throwaway database held only in RAM, so each test starts from a clean, fast, disposable state.

sqldelight in-memory driver test
table-driven test

One test that loops over a list of input/expected rows, so every (version, updatedAt) ordering — including the full tie — is checked by the same assertions.

table driven test
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Test the shared logic once, trust it on both platforms

The highest-value tests live in commonMain’s commonTest: they run on every target, so passing them means the exact logic iOS and Android ship is correct. Drive the Syncer with a fake SyncApi and an in-memory store, simulate two devices editing the same record offline, sync both, and assert they converge to one record — and that swapping the sync order yields the identical result (commutative). Add a table-driven test that exhausts the (version, updatedAt) orderings of merge. None of this touches a real backend, so it holds for the Go and Spring servers alike — they only have to honour the same version rule.

Agent prompt — paste into an agent with repo access

The table-driven test feeds merge() an equal-version, equal-updatedAt pair whose only difference is content. Before you run it: will merge(a,b) and merge(b,a) return the same record, and what decides the winner on that full tie?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP test engineer in this repo.
Context: commonMain has merge(local, remote), Syncer, MeasurementStore; a fake SyncApi can be injected.
Task: Add commonTest coverage for deterministic merge and two-device convergence.
Requirements:
- Table-driven merge test: for every ordering of (version, updatedAt) INCLUDING the full tie (equal version
  AND updatedAt with different content), assert the documented winner AND assert merge(a,b) == merge(b,a);
  merge never throws.
- Convergence test: device A and device B each edit the same id offline (different version/updatedAt), then both sync through a shared fake server; assert ONE final record and that A-then-B equals B-then-A.
- Use an in-memory store and a fake SyncApi; no platform code, no network.
Tests / acceptance:
- `./gradlew :shared:allTests` passes (the suite runs on JVM and iOS targets).
Output: a unified diff plus a one-paragraph statement of the invariant the convergence test guarantees.
What success looks like

./gradlew :shared:allTests runs green on JVM and iOS targets. The table-driven test exhausts every (version, updatedAt) ordering — including the full tie, where the stable content compare makes merge(a, b) == merge(b, a) — and the two-device test confirms A-then-B equals B-then-A, both landing on one record:

> Task :shared:allTests
SyncMergeTest > merge is total and commutative over all orderings PASSED
SyncMergeTest > two devices converge to one record regardless of sync order PASSED
BUILD SUCCESSFUL

Model the server store in PostgreSQL

Advanced

Design the relational server schema — a minimal users table, the versioned measurements, and an append-only sync_log — so db/schema.sql applies on an empty DB and every query scopes by user_id. The schema is the durable cross-device system of record and the privacy boundary.

New in this step
FOREIGN KEY / REFERENCES

Forces measurements.user_id to point at a real users row, so health data can’t reference a user who doesn’t exist — which is why users is created first.

postgres foreign key references track ↗ docs ↗
TIMESTAMPTZ

A timestamp stored in UTC, so recorded_at/updated_at are unambiguous across time zones and match the RFC 3339 wire format.

postgres timestamptz vs timestamp track ↗ docs ↗
append-only log

A table you only ever insert into (never update or delete); here sync_log records every accepted change so pulls are cheap and replayable.

append only audit log table
GENERATED ALWAYS AS IDENTITY

The modern auto-incrementing 64-bit key; sync_log.seq uses it, and that ever-increasing number doubles as the pull cursor.

postgres identity column track ↗
idempotent schema

A script safe to run repeatedly — CREATE TABLE IF NOT EXISTS plus ON CONFLICT DO NOTHING — so re-applying it on an existing DB is a no-op.

create table if not exists idempotent
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Health data: lean relational, with integrity and an audit trail

The server is the durable system of record across devices. Keep it relational: one measurements row per record id, carrying the same version the client merges on, so the server applies a write only when version < $incoming — stale writes are no-ops and the server stays consistent with the client’s rule. A sync_log append-only table records every accepted change so you can audit, debug merges, and serve the “changed since cursor” pull query efficiently. This schema is identical whichever backend hosts the endpoint — the SQL is the contract; the Go or Spring code is the shell.

measurements.user_id is a real FK, so the schema creates users first and seeds one dev user — that way db/schema.sql applies on a truly empty DB and the device can sync immediately with no auth UI.

The privacy boundary, in one place

This table is where the project’s data-minimisation rule lives, so name it once here: raw per-record health data stays on-device and in this user-scoped server table; only derived aggregates (ai-insights) or a transient phrase (nl-logging) ever leave to a third party. Every sync query is scoped by the user_id the server resolves from the Bearer token — never a body field — so health data cannot leak across accounts.

server db/schema.sql (applies on an empty DB)
Run these in your terminal / editor
-- 1) Identity first (measurements FK-references it). Minimal on purpose.
CREATE TABLE IF NOT EXISTS users (
  id    UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),  -- gen_random_uuid is built into PG13+
  token TEXT NOT NULL UNIQUE        -- the Bearer token the device stores; maps a request to this user_id
);

-- A deterministic dev user so a fresh local DB runs end-to-end with no auth UI.
INSERT INTO users (id, token)
VALUES ('00000000-0000-0000-0000-000000000001', 'dev-token')
ON CONFLICT DO NOTHING;

-- 2) The synced records.
CREATE TABLE IF NOT EXISTS measurements (
  id           UUID        NOT NULL,
  user_id      UUID        NOT NULL REFERENCES users(id),
  kind         TEXT        NOT NULL,
  value        DOUBLE PRECISION NOT NULL,
  unit         TEXT        NOT NULL,             -- canonical unit only
  recorded_at  TIMESTAMPTZ NOT NULL,
  updated_at   TIMESTAMPTZ NOT NULL,
  version      BIGINT      NOT NULL CHECK (version > 0),
  PRIMARY KEY (user_id, id)
);

-- Range scans for time-series queries and the pull-since-cursor path.
CREATE INDEX IF NOT EXISTS measurements_user_time ON measurements (user_id, recorded_at);
CREATE INDEX IF NOT EXISTS measurements_user_updated ON measurements (user_id, updated_at);

-- 3) Append-only audit: every accepted change, newest cursor = max(seq).
CREATE TABLE IF NOT EXISTS sync_log (
  seq        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    UUID        NOT NULL,
  record_id  UUID        NOT NULL,
  version    BIGINT      NOT NULL,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sync_log_user_seq ON sync_log (user_id, seq);
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: Fresh Postgres 16. Clients sync measurements carrying id, version, updated_at in canonical units, over
a Bearer token. Auth issuance is out of scope; a minimal users table holds the token -> user_id mapping.
Task: Create db/schema.sql with users (seeded), measurements, and an append-only sync_log as specified.
Requirements:
- Create users FIRST (measurements FK-references it); users(id UUID PK default gen_random_uuid(), token TEXT UNIQUE).
- Seed one dev user id 00000000-0000-0000-0000-000000000001 with token 'dev-token' (idempotent, ON CONFLICT DO NOTHING).
- measurements PK (user_id, id); version BIGINT CHECK (> 0); unit stores the canonical unit only.
- Indexes on (user_id, recorded_at) and (user_id, updated_at); sync_log append-only with an identity seq usable
  as the pull cursor (index (user_id, seq)); no UPDATE/DELETE path.
Tests / acceptance:
- `psql "$DATABASE_URL" -f db/schema.sql` applies cleanly on an EMPTY DB (no pre-existing users table) and is re-runnable.
- A second upsert with a LOWER version than the stored row is rejected by the write query (WHERE measurements.version < EXCLUDED.version).
Output: a unified diff plus a note on which integrity rules the DB enforces vs the app, and the auth-out-of-scope boundary.
What success looks like

psql "$DATABASE_URL" -f db/schema.sql applies cleanly on an empty database and is re-runnable (the CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING seed make it idempotent), leaving users, measurements, and sync_log plus the seeded dev-token user:

CREATE TABLE
INSERT 0 1
CREATE TABLE
...
SELECT token FROM users;  -> dev-token

Stand up Postgres locally for the sync server

Beginner

Start a Postgres container with Docker Compose and export a DATABASE_URL your sync server will read — so every learner gets the same throwaway database with one command and the server knows where to find it.

New in this step
Docker Compose

A YAML file that defines and runs containers (here one Postgres) so the whole team gets an identical, resettable database.

docker compose quickstart
DATABASE_URL

An environment variable holding the connection string; reading it from the env means the same server build runs locally, in CI, and on Cloud Run.

twelve-factor config environment
postgres:// connection string (DSN)

The single-line address of a database: user:password@host:port/dbname plus options.

postgres connection string format track ↗ docs ↗
sslmode=disable

Turns off TLS for the local container (fine for localhost; never for a real server).

libpq sslmode
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same Postgres for either backend

A throwaway Postgres in Docker gives every learner the same version and a clean reset (docker compose down -v). The sync server — Go or Spring — reads DATABASE_URL from the environment, so the same build runs locally, in CI, and on Cloud Run; only the connection string changes. Apply db/schema.sql from the previous step against this container before running the server.

docker-compose.yml
Run these in your terminal / editor
# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: vitals
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]
volumes: { pgdata: {} }
Start it and apply the schema
Run these in your terminal / editor
docker compose up -d
export DATABASE_URL="postgres://postgres:dev@localhost:5432/vitals?sslmode=disable"
psql "$DATABASE_URL" -f db/schema.sql

Expose the sync endpoint with Go

Go Advanced

Build the Go sync server’s POST /sync/push and GET /sync/pull over pgx, applying the same version rule the client merges on — so the server and client can never disagree about who wins a conflict.

New in this step
pgx / pgxpool

Go’s Postgres driver and connection pool; you run parameterised SQL through it — never string-concatenate values.

pgx pgxpool postgres go track ↗ docs ↗
transaction

A BEGIN/COMMIT unit where the upsert and its sync_log insert either both land or both roll back, so the audit log can’t drift from the data.

postgres transaction begin commit rollback track ↗
ON CONFLICT DO UPDATE version guard

An upsert that updates an existing row only WHERE measurements.version < EXCLUDED.version, so a stale (lower-version) write changes nothing.

postgres on conflict do update where track ↗
RETURNING

Hands back the id of a row the write actually touched, so a returned id means “accepted” and no row means the guard rejected it.

postgres returning clause track ↗ docs ↗
pgx.ErrNoRows

The error Scan returns when the guarded write matched no row — the signal to skip a stale record, not a failure.

pgx ErrNoRows no rows track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The Go server obeys the same merge contract

The endpoint must agree with the client or sync diverges. On push, upsert each record only if its version beats the stored one (INSERT … ON CONFLICT … DO UPDATE … WHERE measurements.version < EXCLUDED.version), append accepted changes to sync_log in the same transaction, and return the accepted ids. On pull, return every record with a sync_log.seq greater than the client’s cursor, plus the new max seq as the next cursor. Authenticate the user and scope every query by user_id — health data must never leak across accounts. pgx gives you a fast pool and parameterised queries; never string-concatenate SQL.

Push upsert handler (pgx)
Run these in your terminal / editor
// POST /sync/push — upsert each record under the version guard, log accepted ids, return them.
func (s *Server) push(w http.ResponseWriter, r *http.Request) {
	userID := userFrom(r.Context()) // from auth middleware; never trust the body
	var req struct {
		Records []Measurement `json:"records"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	tx, err := s.pool.Begin(r.Context())
	if err != nil { http.Error(w, err.Error(), 500); return }
	defer tx.Rollback(r.Context()) // no-op after Commit

	accepted := []string{}
	for _, m := range req.Records {
		var id string
		err := tx.QueryRow(r.Context(),
			`INSERT INTO measurements
			   (id, user_id, kind, value, unit, recorded_at, updated_at, version)
			 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
			 ON CONFLICT (user_id, id) DO UPDATE
			   SET value = EXCLUDED.value,
			       unit = EXCLUDED.unit,
			       recorded_at = EXCLUDED.recorded_at,
			       updated_at = EXCLUDED.updated_at,
			       version = EXCLUDED.version
			 WHERE measurements.version < EXCLUDED.version
			 RETURNING id`,
			m.ID, userID, m.Kind, m.Value, m.Unit, m.RecordedAt, m.UpdatedAt, m.Version,
		).Scan(&id)
		switch {
		case err == pgx.ErrNoRows: // stale write: version guard rejected it, no-op
			continue
		case err != nil:
			http.Error(w, err.Error(), 500)
			return
		}
		if _, err := tx.Exec(r.Context(),
			`INSERT INTO sync_log (user_id, record_id, version) VALUES ($1, $2, $3)`,
			userID, m.ID, m.Version); err != nil {
			http.Error(w, err.Error(), 500)
			return
		}
		accepted = append(accepted, id)
	}
	if err := tx.Commit(r.Context()); err != nil { http.Error(w, err.Error(), 500); return }
	writeJSON(w, map[string]any{"acceptedIds": accepted})
}
Pull query: join sync_log's max(seq) back to measurements
Run these in your terminal / editor
-- GET /sync/pull?cursor=N — sync_log carries no payload, so take each record's latest
-- seq, keep those past the cursor, then JOIN back to measurements for the row body.
WITH latest AS (
  SELECT record_id, max(seq) AS seq
  FROM sync_log
  WHERE user_id = $1
  GROUP BY record_id
  HAVING max(seq) > $2            -- $2 = the client's cursor N
)
SELECT m.id, m.kind, m.value, m.unit, m.recorded_at, m.updated_at, m.version
FROM measurements m
JOIN latest l ON l.record_id = m.id
WHERE m.user_id = $1
ORDER BY l.seq;                   -- nextCursor = max(l.seq) over this set, or N if empty
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: db/schema.sql applied; pgxpool is s.pool; auth middleware puts a user_id (UUID) in the request context.
Measurement is a Go struct with json tags matching the client (id, kind, value, unit, recordedAt, updatedAt, version).
Task: Implement POST /sync/push and GET /sync/pull in net/http matching the client's version-merge rule.
Requirements:
- push: ONE transaction; upsert each record with ON CONFLICT ... WHERE measurements.version < EXCLUDED.version
  using RETURNING id; on pgx.ErrNoRows treat the write as a rejected stale no-op and skip it.
- For each accepted record, INSERT into sync_log in the same tx; respond {"acceptedIds": [...]}.
- pull: read ?cursor=N (default 0); return records whose sync_log.seq > N for this user, plus nextCursor = max(seq).
- Every query scoped by user_id from context (never the body); parameterised SQL only; values/units stay canonical.
- A stale push (lower version) leaves the stored row untouched and its id is NOT in acceptedIds.
Tests / acceptance:
- Integration test against the Compose Postgres: push v2 over stored v1 wins; push v1 over stored v2 is rejected and the stored value/version is intact.
- pull returns exactly the records changed after the given cursor and a strictly larger nextCursor.
- `go test ./... -run TestSync` passes against DATABASE_URL.
Output: a unified diff plus a sequence diagram of one push/pull round-trip.
What success looks like

A fresh-id push wins the guard and returns its id; a push of the same id at a lower version is rejected by WHERE measurements.version < EXCLUDED.version, so it is omitted from acceptedIds and the stored row is untouched. A pull past the cursor returns exactly the post-cursor records with a strictly larger nextCursor:

POST /sync/push (v3 over stored v2) -> 200 {"acceptedIds":["f1c2…"]}
POST /sync/push (v1 over stored v3) -> 200 {"acceptedIds":[]}
GET  /sync/pull?cursor=0            -> 200 {"records":[{…,"version":3}],"nextCursor":5}

Expose the sync endpoint with Spring Boot (Kotlin)

Spring Boot (Kotlin) Advanced

Build the sync server’s POST /sync/push and GET /sync/pull in Spring Boot (Kotlin) with JdbcTemplate, applying the same version rule the client merges on — byte-identical SQL to the Go path so either backend behaves the same.

New in this step
Spring Boot

A JVM web framework; here it hosts the same two endpoints in Kotlin, so the sync server’s language is swappable around one SQL contract.

spring boot kotlin overview track ↗ docs ↗
JdbcTemplate

Spring’s thin SQL helper that runs parameterised queries and keeps the version-guard SQL explicit (JPA would hide it).

spring jdbctemplate parameterised query
@Transactional

Wraps a method so its upsert and sync_log insert commit or roll back as one unit — the declarative equivalent of Go’s Begin/Commit.

spring transactional annotation
ON CONFLICT DO UPDATE version guard

An upsert that updates an existing row only WHERE measurements.version < EXCLUDED.version, so a stale (lower-version) write changes nothing.

postgres on conflict do update where track ↗
RETURNING

Hands back the id of a row the write touched; an empty queryForList means the guard rejected the write as stale.

postgres returning clause track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same contract, declarative transaction

Spring Boot is a classic JVM service home, and its declarative @Transactional is a clean vehicle for the push path — the whole upsert-plus-audit loop commits or rolls back as a unit. We use Kotlin (not Java) — same JVM, less ceremony. JdbcTemplate keeps the SQL explicit so the version-guard lesson stays front-and-centre; Spring Data JPA would hide it. The conditional upsert is byte-for-byte the Go version’s SQL: ON CONFLICT … WHERE measurements.version < EXCLUDED.version. Scope every query by user_id from the authenticated principal, never the request body.

Push upsert (JdbcTemplate + @Transactional)
Run these in your terminal / editor
@Service
class SyncService(private val jdbc: JdbcTemplate) {

    @Transactional
    fun push(userId: UUID, records: List<Measurement>): List<String> {
        val accepted = mutableListOf<String>()
        for (m in records) {
            // RETURNING id yields a row only when the version guard passes; queryForList stays empty otherwise.
            val returned = jdbc.queryForList(
                """INSERT INTO measurements
                     (id, user_id, kind, value, unit, recorded_at, updated_at, version)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                   ON CONFLICT (user_id, id) DO UPDATE
                     SET value = EXCLUDED.value,
                         unit = EXCLUDED.unit,
                         recorded_at = EXCLUDED.recorded_at,
                         updated_at = EXCLUDED.updated_at,
                         version = EXCLUDED.version
                   WHERE measurements.version < EXCLUDED.version
                   RETURNING id""",
                String::class.java,
                m.id, userId, m.kind, m.value, m.unit, m.recordedAt, m.updatedAt, m.version,
            )
            if (returned.isEmpty()) continue // stale write rejected by the guard — no-op
            jdbc.update(
                "INSERT INTO sync_log (user_id, record_id, version) VALUES (?, ?, ?)",
                userId, m.id, m.version,
            )
            accepted += returned.first()
        }
        return accepted
    }
}
Pull query: join sync_log's max(seq) back to measurements (identical SQL to Go)
Run these in your terminal / editor
-- GET /sync/pull?cursor=N — byte-identical to the Go path; only the ? placeholders differ from $1/$2.
-- sync_log has no payload, so take each record's latest seq, keep those past the cursor, JOIN to measurements.
WITH latest AS (
  SELECT record_id, max(seq) AS seq
  FROM sync_log
  WHERE user_id = ?
  GROUP BY record_id
  HAVING max(seq) > ?              -- the client's cursor N
)
SELECT m.id, m.kind, m.value, m.unit, m.recorded_at, m.updated_at, m.version
FROM measurements m
JOIN latest l ON l.record_id = m.id
WHERE m.user_id = ?
ORDER BY l.seq;                    -- nextCursor = max(l.seq) over this set, or N if empty
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: Spring Boot 3 (Kotlin), spring-boot-starter-web + spring-boot-starter-jdbc, Postgres via
spring.datasource.url=$DATABASE_URL. db/schema.sql applied. The authenticated user_id (UUID) comes from the
security principal. Measurement is a Kotlin data class matching the client JSON.
Task: Implement SyncService.push and a GET /sync/pull endpoint matching the client's version-merge rule.
Requirements:
- push is @Transactional; upsert each record with ON CONFLICT ... WHERE measurements.version < EXCLUDED.version
  using RETURNING id via jdbc.queryForList(String::class.java, ...); empty result == rejected stale no-op, skip it.
- For each accepted record, INSERT into sync_log in the same transactional method; return the accepted ids.
- pull(cursor: Long): return records whose sync_log.seq > cursor for this user, plus nextCursor = max(seq).
- Every query scoped by userId from the principal (never the body); parameterised JdbcTemplate calls only.
- A stale push (lower version) leaves the stored row untouched and its id is NOT returned.
Tests / acceptance:
- @SpringBootTest + Testcontainers (postgres:16): push v2 over stored v1 wins; push v1 over stored v2 is rejected and the stored row is intact.
- pull returns exactly the records changed after the given cursor and a strictly larger nextCursor.
- `./gradlew test` passes.
Output: a unified diff plus the @DynamicPropertySource wiring of the datasource URL.
What success looks like

Byte-identical to the Go path — the SQL and the JSON shape are the same, only the placeholders differ. A v3-over-v2 push returns its id; a v1-over-v3 push is rejected by the same WHERE measurements.version < EXCLUDED.version guard and is omitted from acceptedIds with the stored row intact; a pull past the cursor returns the post-cursor records with a strictly larger nextCursor:

POST /sync/push (v3 over stored v2) -> 200 {"acceptedIds":["f1c2…"]}
POST /sync/push (v1 over stored v3) -> 200 {"acceptedIds":[]}
GET  /sync/pull?cursor=0            -> 200 {"records":[{…,"version":3}],"nextCursor":5}

Compose the server: pool, auth middleware, routes, graceful shutdown (Go)

Go Advanced

Write cmd/server/main.go — the composition root the handlers assume exists, so the whole server actually boots, authenticates, and stops cleanly. Open and ping the pool, wrap the sync routes in the Bearer-token → user_id middleware, register both routes, and shut down gracefully on SIGINT/SIGTERM.

New in this step
connection pool + startup ping

Open the pool once and Ping immediately, so a bad connection string fails at boot, not on the first user’s request.

pgxpool ping fail fast startup track ↗
auth middleware

A wrapper that runs before each handler, looks the Bearer token up in users, and rejects a missing/unknown token with 401 before any query runs.

go http middleware auth track ↗ docs ↗
request context

The per-request carrier the middleware stores the resolved user_id on, so handlers read it from there — never from the request body.

go context request value track ↗ docs ↗
graceful shutdown (signal.NotifyContext / SIGTERM)

Trap SIGINT/SIGTERM, stop accepting, then drain in-flight syncs — so Cloud Run’s scale-down SIGTERM can’t cut a sync mid-transaction.

go graceful shutdown signal notifycontext sigterm track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The handlers don't run themselves — main wires the pool, the auth, and the lifecycle

The push handler reads userFrom(r.Context()) and assumes a pool and a router already exist. That wiring is its own deliverable — cmd/server/main.go. It does four things, in order. One: open the pool from the connection env and Ping once so a bad DSN fails now, not on the first request (local: DATABASE_URL; Cloud Run: the /cloudsql/… socket from the deploy step). Two: build the auth middleware — the piece userFrom depends on. It reads the Authorization: Bearer <token> header, looks the token up in users (SELECT id FROM users WHERE token = $1), and on a hit stores that user_id in the request context; a missing or unknown token is a 401 before any handler runs. This is exactly why no sync query trusts a body-supplied id — the id can only come from the looked-up principal. Three: register POST /sync/push and GET /sync/pull behind that middleware. Four: trap SIGINT/SIGTERM with signal.NotifyContext, then srv.Shutdown(ctx) on a timeout and close the pool — Cloud Run sends SIGTERM on scale-down, and this keeps an in-flight sync from being cut mid-transaction.

cmd/server/main.go — compose pool, auth, routes, shutdown
Run these in your terminal / editor
func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsnFromEnv()) // DATABASE_URL locally; /cloudsql socket on Cloud Run
	if err != nil { log.Fatal(err) }
	if err := pool.Ping(ctx); err != nil { log.Fatalf("db unreachable: %v", err) } // fail fast
	defer pool.Close()

	srv := &Server{pool: pool}
	mux := http.NewServeMux()
	mux.Handle("POST /sync/push", auth(pool, http.HandlerFunc(srv.push)))
	mux.Handle("GET /sync/pull", auth(pool, http.HandlerFunc(srv.pull)))

	port := os.Getenv("PORT")
	if port == "" { port = "8080" } // Cloud Run injects PORT
	httpSrv := &http.Server{Addr: ":" + port, Handler: mux}

	// Graceful shutdown: SIGINT/SIGTERM -> stop accepting, drain in-flight syncs, close the pool.
	stopCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
	defer stop()
	go func() {
		if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	}()
	<-stopCtx.Done()
	shutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()
	_ = httpSrv.Shutdown(shutCtx)
}

type ctxKey struct{}

// auth resolves the Bearer token to a user_id and puts it in the context; userFrom() reads it.
func auth(pool *pgxpool.Pool, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
		if !ok || token == "" { http.Error(w, "unauthorized", http.StatusUnauthorized); return }
		var userID string
		err := pool.QueryRow(r.Context(), `SELECT id FROM users WHERE token = $1`, token).Scan(&userID)
		if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return } // unknown token
		ctx := context.WithValue(r.Context(), ctxKey{}, userID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func userFrom(ctx context.Context) string { return ctx.Value(ctxKey{}).(string) }
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Postgres engineer in this repo.
Context: internal/sync has the push/pull handlers (Server.push reads userFrom(r.Context())); db/schema.sql seeds
users(token='dev-token'). pgx v5 / pgxpool. Locally DATABASE_URL is set; on Cloud Run the connection points at
the /cloudsql/$INSTANCE_CONNECTION_NAME socket (see the deploy step). There is NO existing main or auth middleware yet.
Task: Write cmd/server/main.go — the composition root: open+ping the pool, build the router with auth middleware,
register the two routes, and shut down gracefully.
Requirements:
- Open the pool from the connection env and Ping once on startup; exit non-zero (log.Fatal) if the ping fails.
- auth middleware: read Authorization: Bearer <token>, SELECT id FROM users WHERE token = $1, put the user_id in
  the request context (the value userFrom reads); 401 on a missing OR unknown token, before any handler runs.
- Register POST /sync/push and GET /sync/pull behind auth; listen on $PORT (default 8080).
- Graceful shutdown: signal.NotifyContext(SIGINT, SIGTERM) -> http.Server.Shutdown(ctx) with a timeout -> pool.Close().
- No sync query may read a user id from the request body; it comes only from the authenticated context.
Tests / acceptance:
- A request with no/garbage Bearer token gets 401 and never reaches a handler; Bearer dev-token resolves to the seeded user.
- `go build ./...` passes; `go run ./cmd/server` starts, serves /sync/*, and exits 0 on SIGTERM after draining.
Output: a unified diff plus a one-paragraph note on why the token->user_id lookup lives in middleware, not the handlers.
What success looks like

go run ./cmd/server boots only if the pool pings (a bad DSN exits non-zero immediately). A request with no or an unknown Bearer token gets 401 before any handler runs; Bearer dev-token resolves to the seeded user and reaches /sync/push. On SIGTERM the server drains in-flight syncs, then exits 0:

curl -s localhost:8080/sync/pull               -> 401 unauthorized
curl -s -H 'Authorization: Bearer dev-token' \
     'localhost:8080/sync/pull?cursor=0'       -> 200 {"records":[],"nextCursor":0}

Compose the server: datasource, auth filter, routes, graceful shutdown (Spring/Kotlin)

Spring Boot (Kotlin) Advanced

Assemble the Spring Boot app at parity with the Go composition, so the same four jobs are done the Spring way: a fail-fast datasource, a Bearer-token → user_id filter feeding the principal, the two mapped routes, and built-in graceful shutdown.

New in this step
HikariCP + initialization-fail-timeout

Spring auto-configures the HikariCP pool from spring.datasource.*; setting the fail timeout makes a bad URL fail at boot rather than on the first query — the fail-fast ping equivalent.

spring boot hikari initialization-fail-timeout
OncePerRequestFilter

A servlet filter that runs once per request; it looks the Bearer token up, exposes the user_id to the controller, and 401s a missing/unknown token before the controller runs.

spring onceperrequestfilter bearer token
server.shutdown=graceful

A property that tells Spring to stop accepting and drain in-flight requests on SIGTERM — parity with the Go srv.Shutdown, no extra code.

spring boot graceful shutdown property
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same four jobs, the Spring way — and the framework gives you two for free

The Spring endpoint step assumed an authenticated user_id and a wired datasource; this step builds them. The pool: Spring Boot auto-configures a HikariCP DataSource from spring.datasource.*; set spring.datasource.hikari.initialization-fail-timeout so a bad URL fails on boot (the fail-fast ping equivalent) rather than on the first query. Auth: a OncePerRequestFilter reads Authorization: Bearer <token>, looks it up (SELECT id FROM users WHERE token = ?), and on a hit puts the resolved user_id where the controller reads it (request attribute or a SecurityContext principal); a missing or unknown token returns 401 before the controller runs — the same rule as the Go middleware, so a body-supplied id is never trusted. Routes: the @RestController maps POST /sync/push and GET /sync/pull. Shutdown: set server.shutdown=graceful (and a spring.lifecycle.timeout-per-shutdown-phase) so Spring stops accepting and drains in-flight requests on SIGTERM — no extra code, parity with the Go srv.Shutdown.

Application + auth filter + graceful-shutdown config
Run these in your terminal / editor
@SpringBootApplication
class VitalsApplication

fun main(args: Array<String>) { runApplication<VitalsApplication>(*args) }

// Bearer token -> user_id, mirroring the Go auth middleware. Registered as a servlet Filter.
@Component
class BearerAuthFilter(private val jdbc: JdbcTemplate) : OncePerRequestFilter() {
    override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
        val token = req.getHeader("Authorization")?.removePrefix("Bearer ")?.takeIf { it.isNotBlank() }
        val userId = token?.let {
            jdbc.queryForList("SELECT id FROM users WHERE token = ?", String::class.java, it).firstOrNull()
        }
        if (userId == null) { res.sendError(HttpServletResponse.SC_UNAUTHORIZED); return } // missing/unknown token
        req.setAttribute("userId", userId) // the controller reads this; never a body field
        chain.doFilter(req, res)
    }
}
application.properties — fail-fast datasource + graceful shutdown
Run these in your terminal / editor
# Cloud SQL via the JDBC SocketFactory on Cloud Run; a plain jdbc:postgresql://localhost/vitals locally.
spring.datasource.url=${JDBC_DATABASE_URL:jdbc:postgresql://localhost:5432/vitals}
spring.datasource.username=${DB_USER:postgres}
spring.datasource.password=${DB_PASSWORD:dev}
# Fail fast on a bad URL at boot, not on the first request (the Go Ping equivalent).
spring.datasource.hikari.initialization-fail-timeout=1
# Drain in-flight syncs on SIGTERM (Cloud Run scale-down) before exiting.
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=10s
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: SyncController/SyncService (push/pull) exist and read an authenticated user_id; db/schema.sql seeds
users(token='dev-token'). spring-boot-starter-web + spring-boot-starter-jdbc. There is NO Application class, auth
filter, or shutdown config yet. On Cloud Run the datasource uses the Cloud SQL JDBC SocketFactory (see the deploy step).
Task: Build the composition: the @SpringBootApplication entrypoint, a Bearer-token -> user_id filter, and the
fail-fast + graceful-shutdown configuration — at parity with the Go cmd/server/main.go.
Requirements:
- A OncePerRequestFilter reads Authorization: Bearer <token>, runs SELECT id FROM users WHERE token = ?, and exposes
  the resolved user_id to the controller (request attribute or SecurityContext); 401 on a missing OR unknown token,
  before the controller runs. The controller reads only that id, never the request body.
- POST /sync/push and GET /sync/pull are mapped via a @RestController.
- Datasource fails fast at boot on a bad URL (e.g. hikari initialization-fail-timeout); set server.shutdown=graceful
  with a per-phase timeout so SIGTERM drains in-flight syncs.
- Keep parity with the Go composition: same auth rule, same routes, equivalent graceful shutdown.
Tests / acceptance:
- @SpringBootTest (Testcontainers postgres:16, schema applied): a request with no/garbage Bearer token gets 401 and
  never reaches the controller; Bearer dev-token resolves to the seeded user and a push succeeds.
- `./gradlew test` passes; the app boots and serves /sync/* and exits cleanly on shutdown.
Output: a unified diff plus a one-paragraph note confirming the auth rule and shutdown behaviour match the Go path.
What success looks like

Parity with the Go composition: the same auth rule and routes. A bad URL fails fast at boot (Hikari initialization-fail-timeout); a missing or unknown Bearer token gets 401 before the controller runs, while Bearer dev-token resolves to the seeded user; server.shutdown=graceful drains in-flight syncs on SIGTERM:

curl -s localhost:8080/sync/pull               -> 401
curl -s -H 'Authorization: Bearer dev-token' \
     'localhost:8080/sync/pull?cursor=0'       -> 200 {"records":[],"nextCursor":0}

Integration-test the version guard (Go)

Go Advanced

Run the push/pull path against a real Postgres in a Go test, asserting that stale writes are rejected and the cursor advances — because the ON CONFLICT guard’s behaviour only exists in a real database, not a mock.

New in this step
integration test vs unit test

A unit test isolates one function; an integration test runs the real components together — here the handler against a real Postgres, which is the only way the version guard’s behaviour shows up.

integration test vs unit test
t.Skip

Skips a test cleanly when a precondition is absent (no DATABASE_URL), so the suite still passes on a machine without a database.

go testing t.Skip track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Mocks lie about ON CONFLICT

The version-guard semantics — ON CONFLICT … WHERE version < EXCLUDED.version returning zero rows on a stale write — only exist in a real database. Point the test at the Compose DB (or a disposable container), seed a row at v2, and assert a v1 push is a no-op while a v3 push wins. Assert pull returns exactly the records after a cursor and a strictly larger nextCursor.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: The Go sync server (push/pull) and db/schema.sql exist; Postgres via DATABASE_URL.
Task: Add integration tests for the version guard and the cursor pull.
Requirements:
- Seed one user and one measurement at version 2; a push of the same id at version 1 leaves value/version unchanged and returns no acceptedIds.
- A push at version 3 updates the row and appends to sync_log; its id IS in acceptedIds.
- pull with the pre-push cursor returns exactly the records changed after it, and nextCursor strictly increases.
- t.Skip cleanly if DATABASE_URL is unset; each test isolates its own user_id.
Tests / acceptance:
- `go test ./... -run TestSyncIntegration` passes against the Compose DB.
Output: a unified diff plus how the test isolates itself between runs.
What success looks like

go test ./... -run TestSyncIntegration passes against the Compose Postgres: a v1 push over a stored v2 leaves the value and version unchanged and returns no acceptedIds; a v3 push wins and appends to sync_log; pull returns exactly the post-cursor records and nextCursor strictly increases:

ok      vitals/internal/sync    0.42s   TestSyncIntegration

Integration-test the version guard (Spring/Kotlin)

Spring Boot (Kotlin) Advanced

Run the push/pull path against a real Postgres with @SpringBootTest + Testcontainers, asserting stale-write rejection and cursor advance — proving the version guard against an actual database, at parity with the Go integration test.

New in this step
@SpringBootTest

Boots the full Spring context for the test, so the real filter, controller, and service run against a real database.

spring boot test integration
Testcontainers

Starts a disposable postgres:16 Docker container for the test and tears it down after, so the guard is proven on a real engine with no manual setup.

testcontainers postgres junit
@DynamicPropertySource

Injects the container’s just-assigned JDBC URL into spring.datasource.url at runtime, so the app connects to the throwaway database.

spring dynamicpropertysource testcontainers
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring engineer in this repo.
Context: SyncService.push and the /sync/pull endpoint exist; Testcontainers + JUnit 5 available.
Task: Add a @SpringBootTest integration test backed by a Postgres Testcontainer.
Requirements:
- Spin up postgres:16 via @Container; apply db/schema.sql on start; wire the datasource via @DynamicPropertySource.
- Seed a user and a measurement at version 2; a push at version 1 leaves the stored row intact and returns no accepted ids.
- A push at version 3 wins and appends to sync_log; pull after the prior cursor returns exactly the changed records and a strictly larger nextCursor.
Tests / acceptance:
- `./gradlew test` passes; the container is reused per class.
Output: a unified diff plus the @DynamicPropertySource wiring of the datasource URL.
What success looks like

./gradlew test passes against the Postgres Testcontainer, asserting the same observables as the Go integration test: a v1 push over stored v2 is rejected and the stored row is intact; a v3 push wins; pull returns exactly the post-cursor records with a strictly larger nextCursor:

SyncIntegrationTest > stale push is rejected, fresh push wins, cursor advances PASSED
BUILD SUCCESSFUL

Wire it together and watch two devices converge

Advanced

Point KtorSyncApi at your locally-running sync server, seed dev-token into SecureStore, then run two clients, edit the same record offline on each, and sync both — the watchable proof that the whole stack reconciles, not just the merge function. Watch the higher version win on both screens.

New in this step
10.0.2.2 vs localhost

The Android emulator reaches the host machine at 10.0.2.2; the iOS simulator shares the host network so localhost works — the one base-URL value that legitimately differs per client.

android emulator 10.0.2.2 ios simulator localhost
cleartext (plain http) traffic

Both platforms block plain http by default; a debug network_security_config (Android) or ATS exception (iOS) allows it for local sync — debug builds only.

android usescleartexttraffic ios ats localhost http
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Why this is the payoff (and how it differs from the commonTest)

The commonTest convergence test proves the merge is total and commutative in memory, with a fake SyncApi. It is the correctness proof. This step is the experience: the real KtorSyncApi, the real Bearer header resolved from SecureStore, the real Go/Spring server applying its own WHERE version < EXCLUDED.version guard, and two actual clients reconciling over the wire. Only here do you confirm that the on-device store, the Ktor client, the auth lookup, and the server’s version guard all agree — the seams between the layers, not just the merge function — which is exactly what an automated unit test cannot show you. This is Definition-of-Done item 4: the watchable end-to-end proof, distinct from the green test.

Two clients editing one record offline is the conflict the whole project exists to resolve. Client A bumps the weight to version = 2; client B bumps the same id to version = 3. Sync A, then sync B: A’s push is accepted, B’s push beats it on version, and when A syncs again its pull merges B’s higher version in. Both devices land on B’s value. Edit in the other order and you get the same result — that is convergence you can see, not assert.

The base-URL gotcha: emulator vs simulator can't both say 'localhost'

The server runs on your host machine’s localhost:8080, but each client reaches the host differently. The Android emulator routes 10.0.2.2 to the host loopback, so its baseUrl is http://10.0.2.2:8080. The iOS simulator shares the host network, so localhost works there: http://localhost:8080. A physical device needs the host’s LAN IP instead. Inject the base URL per platform when you construct KtorSyncApi rather than hardcoding it — this is the one value that legitimately differs between the two clients.

Run the server, then launch two clients
Run these in your terminal / editor
# 1) Server up against the local Docker Postgres (schema already applied earlier).
export DATABASE_URL="postgres://postgres:dev@localhost:5432/vitals?sslmode=disable"
#   Go:     go run ./cmd/server      # listens on :8080
#   Spring: ./gradlew bootRun        # listens on :8080

# 2) Two clients, two terminals. baseUrl differs per platform:
#   Android emulator -> http://10.0.2.2:8080     iOS simulator -> http://localhost:8080
./gradlew :androidApp:installDebug      # client 1: Android emulator
xcrun simctl boot "iPhone 15"           # client 2: iOS simulator (needs macOS + Xcode)

# 3) On EACH client: turn networking off, edit the SAME weight to a different value, turn it back on, sync.
#    Verify directly in Postgres that one version won:
psql "$DATABASE_URL" -c "SELECT id, value, version FROM measurements ORDER BY version DESC LIMIT 1;"
Inject baseUrl + seed the dev token at app startup
Run these in your terminal / editor
// In each app's composition root (androidApp / iosApp), before first sync:
// baseUrl is the ONE value that differs per client.
val baseUrl = "http://10.0.2.2:8080"      // Android emulator; iOS simulator uses "http://localhost:8080"

val secure = SecureStore(/* Android: context */)
if (secure.get("sync_token") == null) {
    secure.put("sync_token", "dev-token")  // matches the users row seeded in db/schema.sql
}

val api = KtorSyncApi(http = httpClient, baseUrl = baseUrl, token = { secure.get("sync_token") })
val syncer = Syncer(store = measurementStore, api = api)
// syncer.sync() now pushes dirty rows and pulls merged ones against the real server.
Agent prompt — paste into an agent with repo access

You'll edit the same weight offline on both clients — Android to 70 kg (version 2), iOS to 71 kg (version 3) — then sync Android first, iOS second. What single value and version does the psql query show, and what does each screen read after its next sync?

For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior KMP engineer in this repo wiring the shared module to the live local server.
Context: commonMain has MeasurementStore, SecureStore (expect/actual), KtorSyncApi, and Syncer. The Go or
Spring sync server is running on the host at :8080 against the local Docker Postgres, whose db/schema.sql
seeded users(token='dev-token'). The Android emulator reaches the host at 10.0.2.2; the iOS simulator at localhost.
Task: Add the composition wiring in each app (androidApp + iosApp) that builds a working Syncer against the
local server, plus a one-tap "Sync now" action on the existing measurement screen.
Requirements:
- Inject baseUrl per platform: "http://10.0.2.2:8080" on Android, "http://localhost:8080" on iOS; do not hardcode one value for both.
- On first launch, if SecureStore.get("sync_token") is null, put "dev-token" so the Bearer header resolves to the seeded user.
- Construct KtorSyncApi(http, baseUrl, token = { secure.get("sync_token") }) and Syncer(store, api); expose syncer.sync() from a button.
- Allow cleartext for the local host in DEBUG builds only (Android network_security_config / iOS ATS exception); never in release.
Tests / acceptance:
- Manual two-client convergence: edit the same measurement id offline on both clients to different values, sync both;
  `psql "$DATABASE_URL" -c "SELECT value, version FROM measurements WHERE id='<id>'"` shows the single higher-version winner,
  and both clients display that same value after their next sync.
- A push with a stale (lower) version is NOT in acceptedIds and leaves the stored row unchanged (observed via the same query).
Output: a unified diff plus a one-paragraph description of what you saw the two devices do.
What success looks like

The payoff you can watch: edit the same id offline on both clients to different values, then sync both. The server’s version guard keeps only the higher version, and after each client’s next pull both screens read the same value. The database confirms one winning row:

psql -c "SELECT value, version FROM measurements WHERE id='<id>'"
 value | version
-------+---------
  71.0 |       3        -- iOS's higher version won; Android shows 71.0 after its next sync

Render the iOS UI in SwiftUI

SwiftUI Intermediate

Build the iOS screens in SwiftUI over the shared KMP framework — native UI calling the same store and Syncer as Android, with iOS owning the HealthKit actual. Keep formatting at the edge; the store stays canonical.

New in this step
KMP-to-Swift framework

KMP compiles the shared module into an Objective-C/Swift-callable framework, so SwiftUI calls the same MeasurementStore and Syncer as Android.

kotlin multiplatform ios framework swift interop track ↗
suspend → Swift async

Kotlin suspend functions surface to Swift as async, so try await syncer.sync() works natively from the UI.

kotlin suspend function swift async interop track ↗
@Observable

The macro that makes a view model’s state drive SwiftUI redraws when it changes.

swiftui observable macro track ↗ docs ↗
List + .refreshable

SwiftUI’s scrolling list with pull-to-refresh; the refresh action awaits a sync.

swiftui list refreshable track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Native SwiftUI over the same shared framework

KMP compiles the shared module into an Objective-C/Swift-callable framework, so SwiftUI calls the same MeasurementStore and Syncer as Android. The iOS app owns the HealthKit actual and the Keychain SecureStore from earlier steps. Suspend functions surface to Swift as async calls. The screens stay native — List, Form, @Observable — while the logic underneath is byte-for-byte the Android logic. One seam to expect: kotlinx.datetime.Instant and Kotlin enums cross the bridge as Kotlin types, so formatInstant/formatForDisplay are small Swift helpers you write over those shared values, not free conversions.

A metric list (SwiftUI)
Run these in your terminal / editor
struct MeasurementList: View {
    let store: MeasurementStore   // the shared KMP type, exposed to Swift
    let syncer: Syncer

    var body: some View {
        List(measurements, id: \.id) { m in
            HStack {
                VStack(alignment: .leading) {
                    Text(m.kind.name)
                    Text(formatInstant(m.recordedAt)).font(.caption)
                }
                Spacer()
                Text(formatForDisplay(m))   // canonical -> user unit at the edge
            }
        }
        .refreshable { try? await syncer.sync() }  // suspend fun -> Swift async
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior iOS engineer (Swift, SwiftUI) in this repo.
Context: The KMP shared module is exposed as a Swift-callable framework with MeasurementStore and Syncer.
HealthKit and Keychain actuals from earlier steps are wired. Suspend funcs appear as Swift async.
Task: Build a SwiftUI metric-list screen and add-sample form over the shared store.
Requirements:
- Pull-to-refresh calls `await syncer.sync()`; show an error alert on failure.
- The add form calls the shared Measurement.validate() and blocks save on failure, showing the message. Initialize new records with `version = 1` to prevent server `CHECK (version > 0)` constraint violations.
- Display converts canonical units to the user's preference; the store keeps canonical only.
- Request HealthKit permissions via the shared HealthBridge before the first read.
Tests / acceptance:
- A SwiftUI ViewModel/unit test: invalid input keeps the save button disabled and surfaces the error.
- Manual: pull-to-refresh triggers a sync and the list reflects merged remote changes.
Output: a unified diff plus a note on how suspend functions map to Swift async here.

Render the Android UI in Jetpack Compose

Jetpack Compose Intermediate

Build the Android screens — a metric list and an add-sample form — over the shared KMP store and Syncer, so the UI just renders what the shared engine produced. Format canonical units only at the display edge.

New in this step
@Composable

A function that describes a piece of UI; Compose redraws it when the state it reads changes.

jetpack compose composable track ↗ docs ↗
observing state

The screen reads the shared store as observable state, so it re-renders automatically after a sync writes new rows.

jetpack compose state observe
format at the edge

The store holds canonical kg/km; convert to the user’s preferred unit only when drawing, so the stored value stays the one true number.

canonical unit display formatting
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Native Compose over shared logic

The Android app is thin: it observes the shared MeasurementStore, triggers Syncer.sync(), and renders. It also owns the Health Connect actual from the bridge step. Keep formatting at the very edge — the store holds canonical kg/km, and the UI converts to the user’s preferred unit only for display. None of the merge or sync logic lives here; the screen just shows what the shared engine produced.

A metric row (Compose)
Run these in your terminal / editor
@Composable
fun MeasurementRow(m: Measurement, prefs: UnitPrefs) {
    Row(Modifier.fillMaxWidth().padding(16.dp), Arrangement.SpaceBetween) {
        Column {
            Text(m.kind.name, style = MaterialTheme.typography.titleMedium)
            Text(formatInstant(m.recordedAt), style = MaterialTheme.typography.bodySmall)
        }
        Text(formatForDisplay(m, prefs))  // e.g. canonical 90.7 kg -> "200 lb" if user prefers lb
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Android engineer (Kotlin, Jetpack Compose) in this repo.
Context: The shared KMP module exposes MeasurementStore and Syncer; the store holds canonical-unit Measurements.
Task: Build a metric-list screen and an add-sample form backed by the shared store.
Requirements:
- Observe the store as state; a "Sync now" action calls Syncer.sync() off the main thread and surfaces success/failure.
- Add-form validates via the shared Measurement.validate() before upsert; show the failure message on invalid input. Initialize new records with `version = 1` to prevent server `CHECK (version > 0)` constraint violations.
- Display formats canonical units to the user's preference; the store keeps canonical only.
Tests / acceptance:
- ViewModel unit test: submitting a -5 kg weight yields a validation-error UI state and no upsert.
- ViewModel unit test: a failed sync sets an error state and does not clear the dirty indicator.
Output: a unified diff plus the ViewModel state machine.

Render the UI in Flutter

Flutter Intermediate

Build the Flutter screens — a metric list and an add-sample form — over the sync server’s HTTP API, re-implementing the offline store in Dart because Flutter can’t consume the KMP shared framework. Reach native health data through platform channels.

New in this step
Dart http package

Flutter’s HTTP client; since there’s no shared KMP here, it posts and gets the sync endpoints directly over the same wire contract.

dart http package flutter track ↗ docs ↗
jsonDecode + fromJson

Parses a JSON response and a fromJson factory maps it onto a typed Dart Measurement, mirroring what the KMP client deserialises.

dart jsondecode fromjson factory track ↗
MethodChannel (platform channel)

Flutter’s bridge to native code; HealthKit/Health Connect still need native calls, which is part of why Flutter scores 3/5 here.

flutter methodchannel platform channel track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Flutter shares UI, not the KMP logic

Flutter is a single-codebase UI, so unlike SwiftUI/Compose it does not consume the KMP shared framework — it talks to the sync server’s POST /sync/push and GET /sync/pull over the http package and re-implements the merge/validation in Dart, or defers it to the server. That re-bridging is exactly why Flutter scores 3/5 here: you still need native code for HealthKit and Health Connect (reached through MethodChannel platform channels, or a vetted plugin such as the health package), and you can’t share the non-UI logic the KMP way. Keep values/units canonical on the wire; format only at the edge.

Fetch + sync (Flutter)
Run these in your terminal / editor
Future<SyncResult> sync(int cursor) async {
  // Push local dirty rows, then pull everything changed since the cursor.
  await http.post(
    Uri.parse('$baseUrl/sync/push'),
    headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
    body: jsonEncode({'records': dirtyRecords.map((m) => m.toJson()).toList()}),
  );
  final res = await http.get(
    Uri.parse('$baseUrl/sync/pull?cursor=$cursor'),
    headers: {'Authorization': 'Bearer $token'},
  );
  final data = jsonDecode(res.body) as Map<String, dynamic>;
  final records = (data['records'] as List).map((j) => Measurement.fromJson(j)).toList();
  return SyncResult(records: records, nextCursor: data['nextCursor'] as int);
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Flutter engineer (Dart) in this repo.
Context: The sync server exposes POST /sync/push ({records:[...]}) and GET /sync/pull?cursor=N -> {records, nextCursor}.
HealthKit/Health Connect are reached via a MethodChannel or the `health` package; there is NO shared KMP code in Flutter.
Task: Build a metric-list screen and an add-sample form, plus a sync action, over the HTTP API.
Requirements:
- A sync notifier (e.g. Riverpod/Bloc) pushes dirty records, then pulls since the stored cursor and persists nextCursor.
- Validate weight (1..635 kg) and heart rate (20..250 bpm) before upsert; reject out-of-range input with a message. Initialize new records with `version = 1` to prevent server `CHECK (version > 0)` constraint violations.
- Read recent samples through a platform channel (or the health package); map native samples to canonical units.
- Display converts canonical units to the user's preference; values/units stay canonical on the wire.
Tests / acceptance:
- A unit test on the notifier: a stale local push is reflected as not-accepted by the server response and the row stays dirty.
- A widget test: submitting a -5 kg weight shows a validation error and does not call the API.
Output: a unified diff plus the sync notifier's state model.

Deploy the sync backend to Cloud Run + Cloud SQL

Advanced

Deploy the sync server to Cloud Run with a managed Cloud SQL Postgres — a serverless rendezvous that scales to zero between syncs, connecting over the Cloud SQL connector socket (no public IP, no password in the image).

New in this step
Cloud Run

A serverless container host that scales to zero between syncs — a good fit because the apps are offline-first and the server is just the rendezvous.

google cloud run overview track ↗ docs ↗
Cloud SQL

Managed Postgres with backups and patching handled, holding the same schema your local Docker Postgres ran.

google cloud sql postgres track ↗ docs ↗
connector socket DSN

The container connects over a Unix socket at /cloudsql/PROJECT:REGION:instance (not a TCP postgres://host URL) — no public IP, with the connection authenticated by the connector.

cloud sql connector socket factory postgres
Secret Manager

Stores the full connection string with the password and injects it as an env var at deploy, so the secret never sits in plaintext or the image.

google secret manager cloud run track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Managed Postgres, serverless sync endpoint — over the connector socket, not a public IP

The mobile apps are offline-first, so the server is “just” the sync rendezvous — a perfect fit for Cloud Run, which scales to zero between syncs. Cloud SQL runs Postgres with backups and patching handled, and the container connects over the Cloud SQL connector: --add-cloudsql-instances mounts a Unix socket at /cloudsql/PROJECT:REGION:vitals-pg and tunnels to the instance with no public IP and no password in the image. So the connection is not a TCP postgres://host:5432/… URL — the Go binary reads a libpq DSN whose host is that socket path, while the Spring jar points its JDBC URL at Cloud SQL through the Cloud SQL JDBC SocketFactory (add the postgres-socket-factory dependency; see the Cloud SQL connector docs). Same schema, same endpoints; only the connection env shape differs by path. This is the exact connector path the Google Cloud track teaches — keep db-f1-micro so the tier matches there too.

Deploy (Cloud SQL connector socket — no TCP URL)
Run these in your terminal / editor
gcloud sql instances create vitals-pg \
  --database-version=POSTGRES_16 --tier=db-f1-micro --region=europe-west1
gcloud sql databases create vitals --instance=vitals-pg
gcloud sql users create app_user --instance=vitals-pg --password='CHANGE_ME'
CONNECTION_NAME=$(gcloud sql instances describe vitals-pg --format='value(connectionName)')

# Go path: dsnFromEnv() reads the WHOLE DATABASE_URL — a libpq DSN whose host is the mounted socket
# (NOT a TCP postgres://host:5432 URL). gcloud --set-env-vars does NOT interpolate other env vars, and
# pgx/libpq does NOT expand $VAR inside a DSN, so the password must already be in the string. Store the
# complete DSN (real password included) in Secret Manager and inject it whole as DATABASE_URL via
# --set-secrets, so the container sees a ready-to-use DSN — no $VAR survives into the connection string.
printf 'postgres://app_user:THE_REAL_PASSWORD@/vitals?host=/cloudsql/%s' "$CONNECTION_NAME" \
  | gcloud secrets create vitals-database-url --data-file=-
gcloud run deploy vitals-sync \
  --source . --region europe-west1 --allow-unauthenticated \
  --add-cloudsql-instances "$CONNECTION_NAME" \
  --set-env-vars "INSTANCE_CONNECTION_NAME=$CONNECTION_NAME" \
  --set-secrets "DATABASE_URL=vitals-database-url:latest"

# Spring path instead: Spring reads username/password as SEPARATE datasource properties, not inside the URL,
# so DATABASE_URL does not apply. The URL (JDBC SocketFactory) and DB_USER are plain env vars; only the
# password is secret. No un-interpolated $VAR appears in any value.
#   --set-env-vars "INSTANCE_CONNECTION_NAME=$CONNECTION_NAME,JDBC_DATABASE_URL=jdbc:postgresql:///vitals?cloudSqlInstance=$CONNECTION_NAME&socketFactory=com.google.cloud.sql.postgres.SocketFactory,DB_USER=app_user"
#   --set-secrets "DB_PASSWORD=vitals-db-password:latest"

Compute the weekly aggregates

Optional add-on Advanced

Add a server query that rolls up each user’s week into a handful of numbers — average weight, total steps, average resting heart rate — so only those aggregates, never raw rows, ever leave the device’s boundary toward a model.

New in this step
aggregate functions (avg / sum)

Collapse many rows into one number (average weight, total steps), so a whole week ships as a tiny payload.

postgres aggregate functions avg sum track ↗ docs ↗
FILTER clause

Applies an aggregate to only the rows matching a condition (avg(value) FILTER (WHERE kind = 'WEIGHT')), so one query yields all three per-kind numbers.

postgres aggregate filter clause track ↗
date_trunc

Rounds a timestamp down to a unit (date_trunc('week', now())), so the query keeps only this week’s rows.

postgres date_trunc week track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Aggregate first; it's the privacy boundary too

Insights are additive and off the correctness path, so they read from already-stored data. Roll the week up in Postgres into a tiny payload — never ship raw health rows to a model. The aggregation is the same SQL whichever backend runs it; only the handler glue differs. Bucketing by date_trunc('week', recorded_at) and filtering by user_id keeps each user’s numbers isolated and the request body small (good for privacy and cost alike).

Weekly rollup (SQL)
Run these in your terminal / editor
-- One row of aggregates for the user's current week, in canonical units.
SELECT
  avg(value) FILTER (WHERE kind = 'WEIGHT')                    AS avg_weight_kg,
  sum(value) FILTER (WHERE kind = 'STEPS')                     AS total_steps,
  avg(value) FILTER (WHERE kind = 'HEART_RATE')                AS avg_heart_rate_bpm
FROM measurements
WHERE user_id = $1
  AND recorded_at >= date_trunc('week', now());
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior backend engineer in this repo (use the selected backend).
Context: measurements stores canonical-unit rows per user; the feature is optional and off by default.
Task: Add a weeklyAggregates(userId) function returning {avgWeightKg, totalSteps, avgHeartRateBpm} for the current week.
Requirements:
- One parameterised query bucketed with date_trunc('week', recorded_at) (or now()) and filtered by user_id.
- Return numbers only — never raw measurement rows; null-safe when a kind has no samples this week.
- Scope strictly to the authenticated user_id; no cross-user reads.
Tests / acceptance:
- Integration test: seed a known week and assert avg/sum match hand-computed values; a user with no data returns nulls, not an error.
Output: a unified diff plus a note on why aggregation is the privacy boundary.
What success looks like

The integration test passes: a seeded week’s avg_weight_kg/total_steps/avg_heart_rate_bpm match the hand-computed values, and a user with no samples this week returns null aggregates rather than an error.

Summarise the week with Gemini

Optional add-on Advanced

Turn the weekly aggregates into a short, plain-language summary with the Gemini API — an additive bonus that sends only numbers and degrades to no insight on any failure, so tracking and sync never depend on it.

New in this step
Gemini API

Google’s LLM API; the server sends it only the aggregate numbers and gets back a 2–3 sentence summary.

google gemini api generateContent
key stays server-side

The GEMINI_API_KEY lives in the server env; the app calls your /insights/weekly, never Gemini directly, so the key never ships in the binary.

api key server side never in client
graceful degradation

Any Gemini error or timeout returns 200 {"insight": null} instead of failing, so the insight is a bonus that can never break tracking or sync.

graceful degradation fallback null
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Insight is additive, never load-bearing

Tracking and sync must work with the network off; insights are a bonus when it’s on. Send only the computed aggregates (numbers) — never raw health rows — to Gemini for a plain-language summary, and degrade gracefully to “no insight available” on failure. This is exactly why Gemini scores 3/5 here: pleasant, but the app is whole without it. Keep the API key server-side; the mobile app calls your /insights/weekly endpoint, never Gemini directly. The prompt is templated and lives under prompts/. See the Gemini API docs; link the model docs rather than hardcoding a model name that may change.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: AI integration engineer in this repo (server calling the Gemini API from the selected backend).
Context: weeklyAggregates(userId) -> {avgWeightKg, totalSteps, avgHeartRateBpm} exists. GEMINI_API_KEY in env.
We use the Gemini API: https://ai.google.dev/gemini-api/docs . The feature must be fully optional.
Task: Add a GET /insights/weekly endpoint that turns the aggregates into a 2-3 sentence summary via Gemini.
Requirements:
- Send ONLY the computed aggregates (numbers), never raw measurement rows; no PII beyond the user's own metrics.
- The prompt is templated and lives under prompts/; cap output tokens; set a request timeout (e.g. 20s).
- On any Gemini error/timeout, return 200 with {"insight": null} so the app degrades gracefully.
- Feature-flagged off by default; tracking and sync never depend on it; link the model docs, don't hardcode a model name that may change.
Tests / acceptance:
- Unit test: a mocked Gemini failure yields {"insight": null}, not a 5xx.
- Unit test: the request body to Gemini contains only the aggregate fields (assert no raw rows are serialised).
Output: a unified diff plus the prompt template and a note on the privacy boundary.
What success looks like

A mocked Gemini failure returns 200 {"insight": null} rather than a 5xx, so tracking and sync never depend on the model being up; the request body asserts only the aggregate fields are serialised — no raw measurement rows leave the server.

Surface the insight in the app

Optional add-on Intermediate

Add a dismissible weekly-insight card to each UI that fetches /insights/weekly and renders the summary — a bonus surface that shows nothing when the insight is null and never blocks the tracking screen.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Mobile engineer in this repo (use the selected frontend: SwiftUI, Jetpack Compose, or Flutter).
Context: GET /insights/weekly returns {"insight": string | null}; the feature is optional and may be off.
Task: Add a weekly-insight card to the home screen that fetches the endpoint and renders the summary.
Requirements:
- Fetch off the main thread; show a compact loading state, then the insight text, or nothing when insight is null.
- The card is dismissible and never blocks the tracking UI; a fetch failure hides the card silently (no error wall).
- No raw health data is sent from the client — it only reads the server's summary string.
Tests / acceptance:
- A unit/UI test: a null insight renders no card; a non-null insight renders the text and a dismiss control.
Output: a unified diff plus where the card sits in the view hierarchy.

Map a parsed phrase to the shared domain in commonMain

Optional add-on Intermediate

Turn the backend’s structured parse result into validated Measurements with the existing canonical() and validate(), then save them through the existing MeasurementStore.upsert so they ride the same Syncer — adding no new sync path, only a nullable label column proven by a real migration.

New in this step
on-device migration

A device that already ran v1 has a v1 SQLite file; adding a field means upgrading that real file, not just changing Kotlin.

sqlite schema migration on device
.sqm file + schemaVersion

SQLDelight runs numbered .sqm files in order; migrations/1.sqm takes the schema v1 → v2, and you bump schemaVersion to 2 so a fresh install also lands at v2.

sqldelight sqm migration schemaversion
backward-compatible nullable ADD COLUMN

ADD COLUMN label TEXT (nullable, default null) leaves every existing row valid with label = NULL — no rewrite, no data loss, old code still works.

alter table add column nullable backward compatible
server ALTER TABLE parity

The server mirrors the change with ALTER TABLE measurements ADD COLUMN label TEXT, and label joins the push handler’s DO UPDATE set so it isn’t silently dropped.

postgres alter table add column track ↗ docs ↗
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
The entry model is shared; only the parse call is new

Natural-language logging adds no new sync path. Your backend returns typed candidate entries; commonMain maps each one to the project’s Measurement and runs it through the same canonical() (kg/km normalisation) and validate() (range checks) the manual add-form already uses. Confirmed entries are written with MeasurementStore.upsert, which marks them dirty = 1, so the existing Syncer.sync() pushes them on the next sync — iOS, Android, and the server stay identical because nothing here is platform-specific.

Most extracted signals already fit the model: a weight is MetricKind.WEIGHT (kg), “ran 5k” is MetricKind.DISTANCE (km), a step count is MetricKind.STEPS. The one genuinely new shape is a symptom (“felt dizzy”), which is categorical — so this step makes the single additive extension the feature needs: a SYMPTOM kind and a nullable label for its text. It defaults to null, so every existing numeric record and the on-device table stay backward-compatible, and SYMPTOM falls through validate()’s existing value >= 0 branch — nothing else changes.

Because the base store shipped a v1 schema without label, a learner who already ran the app has a v1 SQLite file on the device. Adding the field is therefore a real on-device migration, not just a Kotlin change — and that is the lesson this feature proves: a nullable column is a backward-compatible schema change. SQLDelight runs numbered .sqm files in order, so author migrations/1.sqm to take the schema from v1 to v2 with a single ALTER TABLE measurement ADD COLUMN label TEXT, bump the SQLDelight schemaVersion to 2 in the Gradle config, and add the label column to the measurement table definition (so a fresh v2 install has it) plus the label parameter to upsert/upsertClean (so saved entries carry it). An existing v1 DB migrates forward, gaining a NULL label on every old row; a fresh install starts at v2. The server side mirrors this with an ALTER TABLE measurements ADD COLUMN label TEXT against the deployed Postgres (or just the v2 schema.sql on an empty DB), and label joins the push handlers’ DO UPDATE set.

The privacy boundary lives at this seam: the app sends the phrase to your backend and gets back structure; the raw Measurement rows stay on-device and only move through the sync engine you already built.

Candidate entry + mapping (commonMain)
Run these in your terminal / editor
// shared/src/commonMain/kotlin/health/Parsed.kt
package health

import kotlinx.datetime.Instant

// What /parse returns, before the user confirms — one per extracted signal.
data class ParsedEntry(
    val kind: MetricKind,
    val value: Double,
    val unit: MetricUnit,
    val recordedAt: Instant,
    val label: String? = null,     // e.g. "dizzy" for a SYMPTOM; null for plain metrics
)

// Reuse the existing canonical()/validate(); never re-implement the unit math here.
fun ParsedEntry.toMeasurement(id: String, now: Instant): Result<Measurement> =
    Measurement(
        id = id, kind = kind, value = value, unit = unit,
        recordedAt = recordedAt, updatedAt = now, version = 1, label = label,
    ).canonical().validate()
migrations/1.sqm — the v1 -> v2 label column (proves the backward-compatible migration)
Run these in your terminal / editor
-- shared/src/commonMain/sqldelight/health/migrations/1.sqm
-- SQLDelight runs numbered .sqm files in order; this one takes the schema from v1 to v2.
-- A nullable ADD COLUMN is backward-compatible: existing rows get NULL, no rewrite, no data loss.
ALTER TABLE measurement ADD COLUMN label TEXT;
Bump the SQLDelight schemaVersion to 2 (build.gradle.kts, shared)
Run these in your terminal / editor
// build.gradle.kts (shared) — tell SQLDelight a fresh install is at schema 2 and migrations exist.
sqldelight {
    databases {
        create("VitalsDb") {
            packageName.set("health")
            schemaVersion = 2            // was 1; migrations/1.sqm upgrades a v1 device to this
            // verifyMigrations.set(true) // optional: fail the build if a migration doesn't reach schemaVersion
        }
    }
}
Measurement.sq (v2): label on the table + carried in upsert/upsertClean
Run these in your terminal / editor
-- Add `label` to the table so a FRESH install has it, and to upsert/upsertClean so saved entries carry it.
-- Existing v1 devices reach the same shape via migrations/1.sqm above.
-- (the table definition now ends with) ... version INTEGER NOT NULL, dirty INTEGER NOT NULL DEFAULT 1, label TEXT
upsert:
INSERT OR REPLACE INTO measurement(id, kind, value_, unit, recorded_at, updated_at, version, dirty, label)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?);

upsertClean:
INSERT OR REPLACE INTO measurement(id, kind, value_, unit, recorded_at, updated_at, version, dirty, label)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?);
Server-side label parity (Go): add Label to the Measurement struct + 9-column push INSERT
Run these in your terminal / editor
// Add Label to the server's Measurement struct so the wire field is decoded and persisted.
type Measurement struct {
    ID         string    `json:"id"`
    Kind       string    `json:"kind"`
    Value      float64   `json:"value"`
    Unit       string    `json:"unit"`
    RecordedAt time.Time `json:"recordedAt"`
    UpdatedAt  time.Time `json:"updatedAt"`
    Version    int64     `json:"version"`
    Label      *string   `json:"label"` // nullable; non-null only for SYMPTOM entries
}

// In the push handler: 9-column INSERT carrying label, with label = EXCLUDED.label in DO UPDATE.
err := tx.QueryRow(r.Context(),
    `INSERT INTO measurements
       (id, user_id, kind, value, unit, recorded_at, updated_at, version, label)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
     ON CONFLICT (user_id, id) DO UPDATE
       SET value      = EXCLUDED.value,
           unit       = EXCLUDED.unit,
           recorded_at = EXCLUDED.recorded_at,
           updated_at = EXCLUDED.updated_at,
           version    = EXCLUDED.version,
           label      = EXCLUDED.label
     WHERE measurements.version < EXCLUDED.version
     RETURNING id`,
    m.ID, userID, m.Kind, m.Value, m.Unit, m.RecordedAt, m.UpdatedAt, m.Version, m.Label,
).Scan(&id)
Server-side label parity (Spring/Kotlin): add label to Measurement DTO + 9-column push INSERT
Run these in your terminal / editor
// Add label to the server's Measurement data class.
data class Measurement(
    val id: String,
    val kind: String,
    val value: Double,
    val unit: String,
    val recordedAt: java.time.OffsetDateTime,
    val updatedAt: java.time.OffsetDateTime,
    val version: Long,
    val label: String?,  // nullable; non-null only for SYMPTOM entries
)

// In SyncService.push: 9-column INSERT carrying label, with label = EXCLUDED.label in DO UPDATE.
val returned = jdbc.queryForList(
    """INSERT INTO measurements
         (id, user_id, kind, value, unit, recorded_at, updated_at, version, label)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
       ON CONFLICT (user_id, id) DO UPDATE
         SET value       = EXCLUDED.value,
             unit        = EXCLUDED.unit,
             recorded_at = EXCLUDED.recorded_at,
             updated_at  = EXCLUDED.updated_at,
             version     = EXCLUDED.version,
             label       = EXCLUDED.label
       WHERE measurements.version < EXCLUDED.version
       RETURNING id""",
    String::class.java,
    m.id, userId, m.kind, m.value, m.unit, m.recordedAt, m.updatedAt, m.version, m.label,
)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin Multiplatform engineer in this repo.
Context: commonMain already has Measurement, MetricKind, MetricUnit, canonical(), validate(),
MeasurementStore.upsert, and Syncer. A backend POST /parse returns typed candidate entries.
Task: Add a ParsedEntry candidate type and a toMeasurement() mapping in commonMain, the v1 -> v2 label
migration, plus the confirm-then-save wiring, reusing the existing seams.
Requirements:
- Extend the model minimally and additively: add MetricKind.SYMPTOM and a nullable label: String? = null on
  Measurement (default null; existing numeric records stay backward-compatible).
- On-device migration: author shared/src/commonMain/sqldelight/health/migrations/1.sqm with a single
  `ALTER TABLE measurement ADD COLUMN label TEXT`; bump the SQLDelight schemaVersion to 2; add `label TEXT` to
  the measurement table definition and the `label` parameter to upsert/upsertClean. A v1 device must migrate
  forward to v2 (old rows gain NULL label); a fresh install starts at v2.
- Server-side label parity (BOTH backends): add `label TEXT` to the server `measurements` table (via
  `ALTER TABLE measurements ADD COLUMN label TEXT` or the v2 schema.sql on an empty DB); add a nullable
  `Label *string` / `label: String?` field to the server's Measurement struct/DTO so the wire value is
  decoded; update the push handler's INSERT to 9 columns with `label = EXCLUDED.label` in the DO UPDATE set —
  without this the server silently drops the label for every SYMPTOM entry.
- ParsedEntry carries kind, value, unit, recordedAt, optional label; toMeasurement() runs canonical() then
  validate() — do NOT re-implement unit conversion or range checks.
- A confirm(entries) function assigns a client UUID + updatedAt and calls the EXISTING MeasurementStore.upsert;
  saved rows are dirty, so the EXISTING Syncer.sync() ships them (now carrying label). Write no new sync code.
- commonMain stays framework-free; no platform or Gemini types here (the phrase goes to the backend, structure
  comes back).
Tests / acceptance:
- commonTest: a "72.3 kg" candidate maps to a valid WEIGHT Measurement; a "200 lb" candidate canonicalises to
  ~90.72 kg before save.
- commonTest: an out-of-range candidate (e.g. 400 bpm heart rate) fails toMeasurement() and is never upserted.
- commonTest: confirming two candidates leaves two dirty rows that a faked Syncer then pushes.
- Migration test: SQLDelight's verifyMigrations passes; a row written by v1 queries is still readable after the
  v2 migration with label = NULL; a SYMPTOM row round-trips its label.
- `./gradlew :shared:allTests` passes.
Output: a unified diff plus a one-line note on why a nullable ADD COLUMN is a backward-compatible migration.
What success looks like

./gradlew :shared:allTests stays green with the additive change. A 72.3 kg candidate maps to a valid WEIGHT Measurement; a 200 lb candidate canonicalises to ~90.72 kg before save; an out-of-range candidate fails toMeasurement() and is never upserted. SQLDelight’s verifyMigrations passes, and a row written by v1 queries is still readable after the v2 migration with label = NULL:

ParsedEntryTest > 400 bpm heart rate fails toMeasurement(), never upserted PASSED
MigrationTest   > v1 row readable after v2 migration, label = NULL PASSED

Add the /parse endpoint with Gemini structured output (Go)

Optional add-on Intermediate

Add POST /parse { "phrase": "…" } in Go that asks Gemini for structured JSON — a typed array of candidate entries — so a free-text sentence becomes typed records the app can confirm, with no brittle prose parsing.

New in this step
Gemini API

Google’s LLM API; the server sends only the phrase plus the request time and gets back typed candidate entries.

google gemini api generateContent
structured output (responseSchema)

Setting responseMimeType to application/json plus a response schema forces Gemini to return JSON matching your shape, so you get a typed array instead of free prose.

gemini structured output responseschema responsemimetype
graceful degradation

Any Gemini error or timeout returns 200 {"entries": []}, so the app falls back to manual entry and never breaks on a model failure.

graceful degradation empty result fallback
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Structured output turns a sentence into typed entries — server-side

The endpoint takes one free-text phrase and constrains Gemini to emit JSON matching a response schema (a JSON response MIME type plus a response schema in the generation config), so you get a typed array instead of brittle prose parsing. Check the structured output guide for the exact field names in the current SDK, and link the model docs rather than hardcoding a model name that may change. The handler returns candidates only — it never writes to Postgres; the device confirms, then upserts and syncs through the build you already have. Keep the key server-side: the app calls your /parse, never Gemini directly, and you send only the phrase, never stored rows.

Costs nothing. A free Google AI Studio key (free tier) covers every call; the key lives in the server’s env (GEMINI_API_KEY), never in the app.

The candidate-array response schema (conceptual)
Run these in your terminal / editor
POST /parse  { "phrase": "ran 5k this morning, felt dizzy after, weight 72.3kg" }

responseSchema (conceptual) — one object per extracted signal:
{ "type": "array", "items": {
    "type": "object",
    "properties": {
      "kind":  { "type": "string", "enum": ["WEIGHT","STEPS","HEART_RATE","ACTIVE_ENERGY","DISTANCE","SYMPTOM"] },
      "value": { "type": "number" },
      "unit":  { "type": "string", "enum": ["KG","LB","COUNT","BPM","KCAL","KM","MI"] },
      "recordedAt": { "type": "string" },
      "label": { "type": "string" }
    },
    "required": ["kind","value","unit","recordedAt"] } }
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go + Gemini integration engineer in this repo.
Context: The sync server (pgx, /sync/push|pull) exists; auth middleware puts a user_id in the request context.
GEMINI_API_KEY is in the server env. The KMP client maps the returned candidates to Measurement via toMeasurement().
Task: Add POST /parse that sends one phrase to Gemini with a JSON response schema and returns the typed
candidate array.
Requirements:
- Request {"phrase": string}; respond {"entries": [{kind, value, unit, recordedAt, label?}]} matching the shared
  enums (kind incl. SYMPTOM; unit in KG/LB/COUNT/BPM/KCAL/KM/MI).
- Constrain Gemini to JSON via a response schema in the generation config (responseMimeType "application/json"
  plus a response schema); confirm the exact field names against
  https://ai.google.dev/gemini-api/docs/structured-output and link the model docs rather than hardcoding a model name.
- Send ONLY the phrase plus the request time (so relative dates like "this morning" resolve to recordedAt);
  never send stored measurement rows. The key stays server-side.
- Time out after 20s; on a Gemini error/timeout return 200 with {"entries": []} so the app degrades to manual
  entry; validate the JSON before returning.
- /parse persists nothing — the device confirms, then upserts + syncs.
Tests / acceptance:
- "ran 5k this morning, felt dizzy after, weight 72.3kg" yields 3 or more entries incl. a DISTANCE (km), a
  WEIGHT (~72.3 kg), and a SYMPTOM with label "dizzy".
- A mocked Gemini failure returns {"entries": []}, not a 5xx; malformed model JSON is rejected, not echoed.
- `go test ./... -run TestParse` passes with a faked Gemini client.
Output: a unified diff plus a note on the privacy boundary (phrase out, structure in; key server-side).

Add the /parse endpoint with Gemini structured output (Spring/Kotlin)

Optional add-on Intermediate

Add POST /parse { "phrase": "…" } in Spring Boot (Kotlin) that asks Gemini for the same structured JSON — a typed array of candidate entries — at byte-identical parity with the Go endpoint so the KMP mapping is unchanged.

New in this step
Gemini API

Google’s LLM API, called from a @Service so the controller stays thin; the server sends only the phrase plus the request time.

google gemini api generateContent
structured output (responseSchema)

Setting responseMimeType to application/json plus a response schema forces Gemini to return JSON matching your shape, so both backends produce the identical typed array.

gemini structured output responseschema responsemimetype
graceful degradation

Any Gemini error or timeout returns 200 {"entries": []}, so the app falls back to manual entry and never breaks on a model failure.

graceful degradation empty result fallback
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Same contract, same response schema, JVM home

Byte-for-byte the Go endpoint’s contract: one phrase in, a typed candidate array out, constrained by a response schema in the generation config (a JSON response MIME type plus a response schema). Check the structured output guide for the SDK-exact field names, and link the model docs rather than pinning a model name that may change. Call Gemini over the server’s HTTP client from a @Service so the controller stays thin. The endpoint returns candidates only and persists nothing — the device confirms, then upserts and syncs through the existing build. Because the response shape is identical, the KMP toMeasurement() mapping is the same whichever backend the learner picked.

Costs nothing. A free Google AI Studio key (free tier) covers every call; keep it in the server env (GEMINI_API_KEY), never in the app.

The candidate-array response schema (conceptual)
Run these in your terminal / editor
POST /parse  { "phrase": "ran 5k this morning, felt dizzy after, weight 72.3kg" }

responseSchema (conceptual) — one object per extracted signal:
{ "type": "array", "items": {
    "type": "object",
    "properties": {
      "kind":  { "type": "string", "enum": ["WEIGHT","STEPS","HEART_RATE","ACTIVE_ENERGY","DISTANCE","SYMPTOM"] },
      "value": { "type": "number" },
      "unit":  { "type": "string", "enum": ["KG","LB","COUNT","BPM","KCAL","KM","MI"] },
      "recordedAt": { "type": "string" },
      "label": { "type": "string" }
    },
    "required": ["kind","value","unit","recordedAt"] } }
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Kotlin/Spring + Gemini integration engineer in this repo.
Context: The Spring sync server (JdbcTemplate, /sync/push|pull) exists; the authenticated user_id comes from the
security principal. GEMINI_API_KEY is in the env. The KMP client maps candidates to Measurement via toMeasurement().
Task: Add POST /parse that sends one phrase to Gemini with a JSON response schema and returns the typed
candidate array — at parity with the Go endpoint.
Requirements:
- Request {"phrase": string}; respond {"entries": [{kind, value, unit, recordedAt, label?}]} matching the shared
  enums (kind incl. SYMPTOM; unit in KG/LB/COUNT/BPM/KCAL/KM/MI).
- Constrain Gemini to JSON via a response schema in the generation config (responseMimeType "application/json"
  plus a response schema); confirm the field names against
  https://ai.google.dev/gemini-api/docs/structured-output and link the model docs rather than hardcoding a model name.
- Send ONLY the phrase plus the request time (so "this morning" resolves); never send stored rows. The key stays
  server-side in a @Service; the controller is thin.
- Time out after 20s; on a Gemini error/timeout return 200 with {"entries": []} so the app degrades to manual
  entry; validate the JSON before returning.
- /parse persists nothing — the device confirms, then upserts + syncs.
Tests / acceptance:
- "ran 5k this morning, felt dizzy after, weight 72.3kg" yields 3 or more entries incl. a DISTANCE (km), a
  WEIGHT (~72.3 kg), and a SYMPTOM with label "dizzy".
- A mocked Gemini failure returns {"entries": []}, not a 5xx (MockWebServer or a stubbed client); malformed JSON
  is rejected.
- `./gradlew test` passes.
Output: a unified diff plus a note confirming the response shape matches the Go endpoint exactly.

Capture a phrase and confirm before saving

Optional add-on Intermediate

Add a text (and optional voice) input that sends the phrase to POST /parse, shows the typed candidates in a confirmation sheet, and saves only what the user approves — keeping the human in the loop and only ever sending text, never audio, off the device.

New in this step
on-device speech-to-text

The platform transcribes voice to text locally (SFSpeechRecognizer / Android SpeechRecognizer / speech_to_text), so only the text phrase leaves the device — never audio.

ios sfspeechrecognizer android speechrecognizer on device
confirm-before-save flow

Parsed candidates are shown for the user to edit, drop, or accept, and nothing is written until they confirm — an empty or declined parse saves nothing.

confirmation sheet human in the loop
Still fuzzy? Copy this into any AI chat — it explains, it doesn't do the step for you.
Confirm before save — the human stays in the loop

The flow is the same on every UI: a text field — with an optional mic that uses the platform’s on-device speech-to-text to fill that same field, so only text ever leaves the device — posts { phrase } to /parse, and the typed candidates come back. Render them in a confirmation sheet showing the normalised value and unit per entry (each candidate already ran through the shared canonical()/validate()), let the user edit, drop, or accept, and save only on confirm.

SwiftUI and Jetpack Compose call the shared KMP path directly — confirm() then MeasurementStore.upsert then Syncer.sync() — because they already consume the shared framework. Flutter has no shared KMP, so it posts the phrase to /parse, builds the same confirmation sheet, then writes locally and pushes through the existing /sync/push path. Nothing auto-saves: an empty parse or a declined sheet leaves the log untouched.

Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Mobile engineer in this repo (use the selected frontend: SwiftUI, Jetpack Compose, or Flutter).
Context: POST /parse {phrase} -> {entries:[{kind,value,unit,recordedAt,label?}]}. SwiftUI/Compose consume the
shared KMP module (MeasurementStore, Syncer, canonical(), validate(), toMeasurement(), confirm()); Flutter talks
to the server over HTTP and has no shared KMP.
Task: Build a natural-language logging entry: a text field with an optional voice button, a /parse call, and a
confirmation sheet that saves only approved entries.
Requirements:
- The mic uses the platform's on-device speech-to-text (SFSpeechRecognizer / Android SpeechRecognizer /
  speech_to_text) to populate the SAME text field; only the text phrase is sent — never audio, never stored rows.
- Show a loading state during /parse; render each returned candidate with its normalised value + unit and its
  label (for symptoms); allow edit/drop per row.
- On confirm: SwiftUI/Compose call the shared confirm() -> MeasurementStore.upsert so the existing
  Syncer.sync() ships them; Flutter writes locally and pushes via the existing /sync path. Off by default
  (feature module); the manual add-form is unaffected.
- Nothing saves without explicit confirmation; an empty entries array shows a "nothing recognised — add it
  manually" hint and writes nothing.
Tests / acceptance:
- A unit/UI test: a confirmed two-entry sheet results in two upserts (or two POSTed records on Flutter); a
  dismissed sheet results in zero.
- A unit/UI test: an empty /parse response renders the manual-entry hint and performs no writes.
Output: a unified diff plus a note on where only-text-leaves-the-device is enforced.

Where to take it next

  • Go deeper on the shared layer with the Kotlin track — the spotlight here (5/5) is its multiplatform sweet spot, unchanged whether the sync server is Go or Spring.
  • Sharpen the sync server: Go (the default, with pgx) or Kotlin (the Spring Boot path), and harden the store with PostgreSQL.
  • Sharpen the two native UIs with the Jetpack Compose and SwiftUI tracks.
  • See why a single-codebase UI like Flutter scores only 3/5 here on the Compare page — you’d re-bridge native health APIs anyway and lose the shared-logic win.
  • Add the optional weekly summary with Gemini (3/5 — additive, never load-bearing).
  • Want the relational-transaction spotlight instead of the shared-logic one? Build Aurora Commerce, where one ACID checkout makes overselling impossible.