Skip to content

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"
}
}
FieldTypeNotes
packageNamestringDominant 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).
recordCountintNumber 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.
oldestRecordTsISO datetime (UTC)Earliest record timestamp in the bucket.
newestRecordTsISO 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 lastSyncTimestamp is 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 = newestRecordTs of the most recent bucket submitted (NOT now — a slow Health Connect write might land at now - 30s; advancing to now would lose those records on the next sync).
  • On submit failure (network, 401, 422), lastSyncTimestamp is 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):

  1. Pull Walker’s IANA tz from GET /walker/profile.region.id → look up region.timezone in the data layer (Phase 13: hard-coded to Europe/Warsaw since 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 from Calendar.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.

  2. 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.packageName per bucket.
  3. Emit one POST /step/ingest call per day-bucket. Multiple buckets per sync are fine (a 3-day backfill submits 3 requests in series).

  4. 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"
}
}
FieldRequiredPhase 13-4 notes
day, count, source, tz, sampleSpan, sourceBundleId, gyroSamplesObserved, clientSubmittedAt, idempotencyKey, appVersionyesExisting — see POST /step/ingest for full semantics.
deviceModeloptionalDiagnostic only.
provenanceoptionalNew 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.

  • sourceBundleId is the OS-level data source the Walker app reads from (e.g. com.google.android.apps.healthdata is the Health Connect platform service itself on Android; com.apple.health is HealthKit on iOS).
  • provenance.packageName is 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:

  1. Schema validation — Zod parse the body. 422 on shape violation.
  2. Impossible burst ratecount / sampleSpan_seconds <= 12 steps/sec. 422 on violation.
  3. Day-in-futureday may not exceed tomorrow in the claimed tz. 422 on violation.
  4. Hard capcount <= 50_000. 422 on violation.
  5. 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).
  • sourceBundleId allow-list check.
  • provenance.packageName reputation 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:

  1. First app launch (post-onboarding) — Walker checks HealthConnectClient.permissionController.getGrantedPermissions().
  2. 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 via PermissionController.createRequestPermissionResultContract().
  3. User grants — Walker proceeds to first sync (24-hour cap per §2).
  4. User denies — Walker shows the debug walk-input fallback (see §7).
  5. 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/ingest with source: "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 id com.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 HealthConnectClientHKHealthStore
StepsRecord aggregation queryHKQuantityTypeIdentifier.stepCount HKStatisticsCollectionQuery
dataOrigin.packageNameHKSourceRevision.source.bundleIdentifier (e.g. com.apple.Health, com.fitbit.fitbit)
HealthConnectClient.permissionControllerHKHealthStore.requestAuthorization(toShare:read:)
EncryptedSharedPreferences for lastSyncTimestampiOS Keychain (kSecClassGenericPassword, account last_sync_ts, see ADR-0007 §11 for the storage-equivalence pattern).
source: "HealthConnect" in /step/ingest bodysource: "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-AppCheck header) — verified per request. Failed verification rejects with 401 BEFORE the step ingest pipeline runs.
  • sourceBundleId allow-list — server keeps a whitelist of trusted OS pedometer bundles (com.apple.health, com.google.android.apps.healthdata, etc.); unknown sources 422 with SOURCE_NOT_WHITELISTED.
  • provenance.packageName reputation — 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 - 7d triggers OFFLINE_CAP_EXCEEDED 422.
  • 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.timezone in the data layer. Phase 13 hard-codes Europe/Warsaw (Plenny-bound content). Add timezone to RegionSchema at the next region authoring increment so the client can pull walker tz from /walker/profile.region instead 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_ts matches ADR-0007 §11 auth Keychain conventions at Phase 15 dispatch. W-level (tech-architect at Phase 15 ADR).