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
BeginnerCreate 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.
shared module The Gradle module whose code is compiled into every target; the data model, store, and sync engine all live in it.
commonMain The platform-agnostic source set inside the shared module — pure Kotlin that every target shares.
androidMain / iosMain Per-platform source sets that supply the implementations a target needs that pure common code cannot express.
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
# 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:assembleWhat 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.
Normalise and validate units once
BeginnerWrite 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.
kotlin.Result A return type holding either a success value or a failure, so validate() reports a bad value without throwing.
extension function A function added to an existing type (Measurement.canonical()) that reads like a method but lives outside the class.
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)
// 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?
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.successBuild the full local store with SQLDelight
IntermediateAdd 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.
SQLDelight Generates type-safe Kotlin from SQL files and runs on SQLite, so the same queries work from commonMain on both platforms.
.sq file A file of plain SQL (table + named queries) that SQLDelight turns into compile-checked Kotlin functions.
dirty flag A per-row marker meaning “edited locally, not yet pushed”; the Syncer pushes only dirty rows, then clears the flag.
sync cursor The last server sequence number this device pulled; storing it lets each pull fetch only what changed since then.
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)
-- 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)
-- 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
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
IntermediateDeclare 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.
Keychain (iOS) Apple’s encrypted, OS-managed store for small secrets like tokens; the iOS actual writes the Bearer token here.
EncryptedSharedPreferences (Android) Android’s key/value store with the values encrypted at rest; the Android actual writes the Bearer token here.
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)
// 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
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
IntermediateDeclare 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.
Health Connect (Android) Android’s on-device health data store, reached via HealthConnectClient; the Android actual reads typed records from it.
permissions rationale activity A screen Health Connect requires you to register; without it the system rejects your permission requests silently.
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)
// 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
<!-- 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)
<!-- 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
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
IntermediateDeclare 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.
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.
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.
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)
// 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
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
AdvancedWrite 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.
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.
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.
idempotent self-echo Pull returns the rows you just pushed; re-storing them is a harmless no-op, so running sync() twice changes nothing.
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)
// 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?
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 syncTest the merge and sync convergence in commonTest
AdvancedWrite 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.
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.
in-memory store A throwaway database held only in RAM, so each test starts from a clean, fast, disposable state.
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.
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?
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 SUCCESSFULModel the server store in PostgreSQL
AdvancedDesign 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.
TIMESTAMPTZ A timestamp stored in UTC, so recorded_at/updated_at are unambiguous across time zones and match the RFC 3339 wire format.
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.
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.
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.
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)
-- 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
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-tokenStand up Postgres locally for the sync server
BeginnerStart 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.
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.
postgres:// connection string (DSN) The single-line address of a database: user:password@host:port/dbname plus options.
sslmode=disable Turns off TLS for the local container (fine for localhost; never for a real server).
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
# 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
docker compose up -d
export DATABASE_URL="postgres://postgres:dev@localhost:5432/vitals?sslmode=disable"
psql "$DATABASE_URL" -f db/schema.sqlExpose the sync endpoint with Go
Go AdvancedBuild 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.
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.
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.
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.
pgx.ErrNoRows The error Scan returns when the guarded write matched no row — the signal to skip a stale record, not a failure.
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)
// 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
-- 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 emptyAgent prompt — paste into an agent with repo access
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) AdvancedBuild 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.
JdbcTemplate Spring’s thin SQL helper that runs parameterised queries and keeps the version-guard SQL explicit (JPA would hide it).
@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.
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.
RETURNING Hands back the id of a row the write touched; an empty queryForList means the guard rejected the write as stale.
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)
@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)
-- 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 emptyAgent prompt — paste into an agent with repo access
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 AdvancedWrite 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.
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.
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.
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.
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
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
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) AdvancedAssemble 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.
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.
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.
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
@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
# 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=10sAgent prompt — paste into an agent with repo access
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 AdvancedRun 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.
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.
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
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 TestSyncIntegrationIntegration-test the version guard (Spring/Kotlin)
Spring Boot (Kotlin) AdvancedRun 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.
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.
@DynamicPropertySource Injects the container’s just-assigned JDBC URL into spring.datasource.url at runtime, so the app connects to the throwaway database.
Agent prompt — paste into an agent with repo access
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 SUCCESSFULWire it together and watch two devices converge
AdvancedPoint 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.
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.
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
# 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
// 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?
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 syncRender the iOS UI in SwiftUI
SwiftUI IntermediateBuild 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.
suspend → Swift async Kotlin suspend functions surface to Swift as async, so try await syncer.sync() works natively from the UI.
@Observable The macro that makes a view model’s state drive SwiftUI redraws when it changes.
List + .refreshable SwiftUI’s scrolling list with pull-to-refresh; the refresh action awaits a sync.
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)
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
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 IntermediateBuild 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.
observing state The screen reads the shared store as observable state, so it re-renders automatically after a sync writes new rows.
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.
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)
@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
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 IntermediateBuild 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.
jsonDecode + fromJson Parses a JSON response and a fromJson factory maps it onto a typed Dart Measurement, mirroring what the KMP client deserialises.
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 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)
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
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
AdvancedDeploy 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.
Cloud SQL Managed Postgres with backups and patching handled, holding the same schema your local Docker Postgres ran.
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.
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.
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)
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 AdvancedAdd 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.
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.
date_trunc Rounds a timestamp down to a unit (date_trunc('week', now())), so the query keeps only this week’s rows.
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)
-- 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
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 AdvancedTurn 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.
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.
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.
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
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 IntermediateAdd 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
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.Add the /parse endpoint with Gemini structured output (Go)
Optional add-on IntermediateAdd 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.
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.
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.
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)
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
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 IntermediateAdd 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.
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.
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.
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)
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
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 IntermediateAdd 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.
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.
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
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.