API: Health Connect ingest contract
Health Connect ingest contract
The walking-RPG step economy lives on top of two platform pedometer surfaces — Android Health Connect (Phase 13, this contract) and iOS HealthKit (Phase 15 port — same shape, see §iOS port inheritance). This page documents what the mobile client reads from the platform, how it aggregates, and what shape it forwards to POST /step/ingest.
Status: draft contract — implementation in sub-phase 13-4 (Android home screen + step ingest + streak). Updated 2026-05-20. Related: POST /step/ingest, GET /walker/streak, ADR-0006 (mock-trust posture), D-007 (step economy / streak / attestation invariant), D-009 (heavy offline + 7-day cap).
Why this page exists
POST /step/ingest was specified at phase 8a (production target, ADR-0002 / step-ingest.mdx). What was NOT specified: the client-side ingestion contract — how Android reads Health Connect data, what provenance metadata accompanies each bucket, and how multi-day backfill is aggregated before submit. ADR-0006 pulled the production-target App Check / DeviceCheck attestation off the table for the test phase; this page is the test-phase replacement — it documents what the client sends and what the server logs (without validating, per mock-trust posture).
When the production migration unfreezes, App Check + Play Integrity / DeviceCheck layer ON TOP of this contract; the wire shape stays the same.
1. Provenance contract
Health Connect tags every step record with a dataOrigin field — the package name of the writing app (e.g. com.google.android.apps.fitness, com.samsung.android.shealth). The Walker app reads this per record, aggregates per writing-app within the day-bucket, and forwards a provenance envelope to POST /step/ingest:
{ "provenance": { "packageName": "com.google.android.apps.fitness", "recordCount": 247, "oldestRecordTs": "2026-05-20T04:32:00Z", "newestRecordTs": "2026-05-20T20:11:14Z" }}| Field | Type | Notes |
|---|---|---|
packageName | string | Dominant dataOrigin.packageName for the bucket. If a single bucket spans multiple writing apps, the client picks the one contributing the highest step count; minority writers are silently dropped at aggregation (rare in practice — most walkers have one dominant pedometer source per phone). |
recordCount | int | Number of individual Health Connect records aggregated into the bucket. Useful forensics for “did Health Connect deliver 8 records or 800?” — bursty step injectors typically have anomalous record counts. |
oldestRecordTs | ISO datetime (UTC) | Earliest record timestamp in the bucket. |
newestRecordTs | ISO datetime (UTC) | Latest record timestamp in the bucket. |
Mock-trust posture (ADR-0006): the server logs provenance for forensic trail but does not validate the package name against an allow-list or reject buckets from “wrong” writing apps. Production-target validation (the App Check / Play Integrity attestation contract from ADR-0004) is deferred per ADR-0006 §Light anti-cheat. The provenance fields exist on the wire today so that when production validation lands, the contract does not need a breaking change.
Field placement: provenance is a new optional object on the existing POST /step/ingest body (see §5 below). Old client builds that omit it remain accepted (the server treats absence as “unknown provenance” — logged, not rejected, in mock mode). This is a backward-compatible additive extension.
2. Sampling window
The client maintains a lastSyncTimestamp per Walker, stored client-side in EncryptedSharedPreferences (Android — same store as the auth bearer token; per ADR-0007 §3). On each sync attempt:
- Read window =
[lastSyncTimestamp, now]from Health Connect. - First-sync cap = if
lastSyncTimestampis null (cold start, post-onboarding), the read window caps at[now - 24 hours, now]. This prevents unbounded backfill the first time the user grants Health Connect permission to the Walker app — without the cap, a phone that has been recording steps for years would dump everything in one ingest. - On successful submit, advance
lastSyncTimestamp = newestRecordTsof the most recent bucket submitted (NOTnow— a slow Health Connect write might land atnow - 30s; advancing tonowwould lose those records on the next sync). - On submit failure (network, 401, 422),
lastSyncTimestampis NOT advanced — the next sync retries the same window.
Phase 14 (VPS) and Phase 15 (iOS) note: the first-sync cap is mobile-side only; the server has no opinion. iOS port inherits the same 24-hour first-sync cap (HealthKit equivalent — see §iOS port inheritance).
3. Per-day aggregation
Health Connect records are timestamped to the second. The step economy is per-day (D-007: streak counts attested days). The client aggregates per-day buckets in the Walker’s IANA tz before submit.
Aggregation rule (Android side, executed before any POST):
-
Pull Walker’s IANA tz from
GET /walker/profile.region.id→ look up region.timezone in the data layer (Phase 13: hard-coded toEurope/Warsawsince all current content is Plenny-based; future regions extend this).Phase 13 simplification: the current data layer does not carry
region.timezone. The Phase 13 client reads tz fromCalendar.getInstance(TimeZone.getDefault())— i.e. the device tz. Document this drift; mechanics-designer / tech-architect re-litigate at Phase 14+ when content spans multiple wall-clock zones. -
For each Health Connect record in the read window:
- Convert
record.startTime(instant) to the Walker’s tz → local date (YYYY-MM-DD). - Bucket the step count into
bucketsByDay[localDate]. - Track min/max record timestamps + record count + dominant
dataOrigin.packageNameper bucket.
- Convert
-
Emit one
POST /step/ingestcall per day-bucket. Multiple buckets per sync are fine (a 3-day backfill submits 3 requests in series). -
Each POST carries:
day= the bucket’s local date in the Walker’s tz.count= sum of step counts in that bucket.sampleSpanSeconds=(newestRecordTs - oldestRecordTs) in seconds. Used by the server’s burst-rate guard.tz= the Walker’s IANA tz string (e.g.Europe/Warsaw).provenance= §1 envelope.
Why per-day, not per-record: server-side the step economy aggregates to days anyway (D-007 streak invariant); per-record submission would amplify request count 100-1000x with no upside. Per-day buckets also match the existing StepLog PK shape (walker_id × day) so the server’s idempotency story stays simple.
Backfill ordering: the client submits buckets in chronological order (oldest first). If a mid-stream submission fails, the client stops the chain and retries from the failed bucket on next sync. This keeps streak math monotonic — the server’s advanceStreak logic assumes consecutive-day attestations land in order.
4. POST /step/ingest body shape (extended)
The existing wire shape (per POST /step/ingest) is preserved. Phase 13-4 adds the provenance envelope as an optional field, and clarifies that sampleSpanSeconds may be passed as a top-level convenience field in addition to the existing sampleSpan.startUtc/endUtc pair.
// POST /step/ingest body — Phase 13-4 Health Connect contract{ "day": "2026-05-20", "count": 8421, "source": "HealthConnect", "tz": "Europe/Warsaw", "sampleSpan": { "startUtc": "2026-05-20T04:32:00Z", "endUtc": "2026-05-20T20:11:14Z" }, "sourceBundleId": "com.google.android.apps.healthdata", "gyroSamplesObserved": true, "clientSubmittedAt": "2026-05-20T20:42:30Z", "idempotencyKey": "step-ing-01HM4abc", "deviceModel": "Pixel 8", "appVersion": "0.1.0+13-4",
// NEW in Phase 13-4 (additive, backward-compatible) "provenance": { "packageName": "com.google.android.apps.fitness", "recordCount": 247, "oldestRecordTs": "2026-05-20T04:32:00Z", "newestRecordTs": "2026-05-20T20:11:14Z" }}| Field | Required | Phase 13-4 notes |
|---|---|---|
day, count, source, tz, sampleSpan, sourceBundleId, gyroSamplesObserved, clientSubmittedAt, idempotencyKey, appVersion | yes | Existing — see POST /step/ingest for full semantics. |
deviceModel | optional | Diagnostic only. |
provenance | optional | New in Phase 13-4. Logged but not validated in mock mode. Omitting it is accepted (legacy client compatibility). |
sourceBundleId vs provenance.packageName: these are intentionally distinct.
sourceBundleIdis the OS-level data source the Walker app reads from (e.g.com.google.android.apps.healthdatais the Health Connect platform service itself on Android;com.apple.healthis HealthKit on iOS).provenance.packageNameis the third-party writing app that originally recorded the steps (e.g.com.google.android.apps.fitness,com.samsung.android.shealth,com.fitbit.FitbitMobile).
The distinction matters for the production attestation story: sourceBundleId will be validated against a server allow-list (the OS-level pedometer surface is hard to spoof without root); provenance.packageName is forensic-only (any writing app can claim to be any other under Health Connect — that is Health Connect’s design, not a server bug).
5. Anti-cheat (Phase 13 — mock-trust posture)
Per ADR-0006 §Light anti-cheat, the server runs four guards on every POST /step/ingest:
- Schema validation — Zod parse the body. 422 on shape violation.
- Impossible burst rate —
count / sampleSpan_seconds <= 12 steps/sec. 422 on violation. - Day-in-future —
daymay not exceed tomorrow in the claimed tz. 422 on violation. - Hard cap —
count <= 50_000. 422 on violation. - Non-negative count — enforced by Zod (
z.number().int().min(0)).
Provenance is logged but NOT validated. Specifically deferred to the production migration:
- App Check / Play Integrity token verification.
- DeviceCheck attestation (iOS Phase 15).
sourceBundleIdallow-list check.provenance.packageNamereputation check.- TZ-jump detection between consecutive ingests.
- 7-day offline cap enforcement (D-009).
- Cross-source double-counting (where two writing apps both report steps for the same time window).
Acceptable risk: a tester running a mock-step app or step injector will pass these guards. Phase 13 data is loop-validation data (does the streak+energy+tree-point pipeline hang together?), not balance data. Balance data is gathered post-production-migration.
6. Permission flow (Android)
Health Connect requires explicit user permission for step-count reads. The flow:
- First app launch (post-onboarding) — Walker checks
HealthConnectClient.permissionController.getGrantedPermissions(). - If
Permission.read(StepsRecord::class)is not granted — Walker shows a permission rationale screen (“Reading your steps powers the walking-RPG energy system”) and launches the system permission dialog viaPermissionController.createRequestPermissionResultContract(). - User grants — Walker proceeds to first sync (24-hour cap per §2).
- User denies — Walker shows the debug walk-input fallback (see §7).
- User revokes later — Walker re-detects on next sync attempt, returns to step 2.
Phase 13 simplification: the permission rationale screen copy is ENG-only at the wireframe stage; bilingual EN/PL lands during sub-phase 13-11 (i18n pass).
7. Debug walk-input fallback (Phase 13 mock posture)
To keep the Phase 13 dev loop functional on emulators and on devices without Health Connect data, the Walker app ships a debug-only “synthetic walk” input screen behind a developer menu.
- Where: Settings → Developer → Manual step input.
- Build gating: present only in
BuildConfig.DEBUG(release builds strip it). - Inputs: day (date picker), step count (number field 0..50,000),
sampleSpan(auto-derived: start = day 00:00 in walker tz, end = day 23:59 in walker tz, or shorter if user wants). - Submission: posts to
/step/ingestwithsource: "HealthConnect",sourceBundleId: "com.walkrpg.debug.manual",provenance.packageName: "com.walkrpg.debug.manual". The server accepts these in mock mode (the dev-bundle id is not on a real allow-list, and the allow-list does not exist in mock mode — see §5). - iOS Phase 15 inherits the same fallback with
source: "HealthKit"and bundle idcom.walkrpg.debug.manual.
This makes Phase 13’s exit-scenario test runnable on a Pixel emulator with no real step history.
8. iOS port inheritance (Phase 15)
The Phase 15 iOS port consumes HealthKit instead of Health Connect. The wire contract is identical; the platform primitives differ. iOS inherits this entire page with the following substitutions:
| Android (this contract) | iOS (Phase 15) |
|---|---|
Health Connect HealthConnectClient | HKHealthStore |
StepsRecord aggregation query | HKQuantityTypeIdentifier.stepCount HKStatisticsCollectionQuery |
dataOrigin.packageName | HKSourceRevision.source.bundleIdentifier (e.g. com.apple.Health, com.fitbit.fitbit) |
HealthConnectClient.permissionController | HKHealthStore.requestAuthorization(toShare:read:) |
EncryptedSharedPreferences for lastSyncTimestamp | iOS Keychain (kSecClassGenericPassword, account last_sync_ts, see ADR-0007 §11 for the storage-equivalence pattern). |
source: "HealthConnect" in /step/ingest body | source: "HealthKit" in /step/ingest body |
sourceBundleId: "com.google.android.apps.healthdata" | sourceBundleId: "com.apple.health" |
Same per-day aggregation, same provenance envelope, same lastSyncTimestamp advance rule, same 24-hour first-sync cap, same debug fallback. The wire stays single-shape; only the platform plumbing changes per phone.
9. Production-target notes (deferred)
When the production migration unfreezes (post-Phase-16 minimum per D-015), the following layer ON TOP of this contract without changing the wire:
- App Check token (
X-Firebase-AppCheckheader) — verified per request. Failed verification rejects with 401 BEFORE the step ingest pipeline runs. sourceBundleIdallow-list — server keeps a whitelist of trusted OS pedometer bundles (com.apple.health,com.google.android.apps.healthdata, etc.); unknown sources 422 withSOURCE_NOT_WHITELISTED.provenance.packageNamereputation — server logs every (walkerId,packageName) pair; anomalous packageName churn (10 different writers in 24h) flags the walker for QUARANTINE.- Cross-source double-counting — reconciliation worker compares overlapping sample spans across
walkerId × day; double-reports trigger QUARANTINE. - 7-day offline cap (D-009) —
day < now - 7dtriggersOFFLINE_CAP_EXCEEDED422. - TZ-jump guard — consecutive ingests with tz deltas implying impossible travel speed trigger QUARANTINE.
All of these are documented in ADR-0002 (production target). This page documents what ships in Phase 13; ADR-0002 documents what ships at production-migration time.
10. Open follow-ups
region.timezonein the data layer. Phase 13 hard-codes Europe/Warsaw (Plenny-bound content). Addtimezoneto RegionSchema at the next region authoring increment so the client can pull walker tz from/walker/profile.regioninstead of device tz. B-level (mechanics-designer).- Permission rationale copy bilingual. Phase 13-11 i18n sweep. W-level (narrative-designer + ui-designer).
- Backfill batching for >7-day gaps. If a walker reinstalls the app and the 24-hour first-sync cap is in effect, days 2-7 are lost on first sync. Future enhancement: extend the first-sync cap to 7 days, matching D-009’s offline cap. Out of Phase 13 scope; revisit when reconciliation worker lands. B-level (tech-architect, post-Phase-16).
- iOS Keychain key naming. Confirm key naming convention for
last_sync_tsmatches ADR-0007 §11 auth Keychain conventions at Phase 15 dispatch. W-level (tech-architect at Phase 15 ADR).