ADR-0002 — Step ingest pipeline
ADR-0002 — Step ingest pipeline
Status: draft Date: 2026-05-18 Owner: tech-architect Related canon: D-007 (anti-cheat), D-009 (heavy offline, provisional state) Pillar: protects Pillar 1 (Steps = Energy) — anti-idle gate
Status note (2026-05-18): This ADR describes the production target per D-007/D-009. Phase 8b implementation uses the simplified test-phase scope per ADR-0006 — no App Check, no reconciliation worker, no 7-day offline cap enforcement, no cross-source double-counting check. The full pipeline described below is deferred to a later production-migration phase, currently paused.
Context
Walking-derived Energy is the primary game resource. Player can play heavily offline (D-009) for up to 7 days; allocations and quest progress made offline are provisional until server validates step provenance against HealthKit / Health Connect authoritative source. D-007 mandates restrictive anti-cheat with mock-step rejection.
This ADR defines the ingest pipeline that converts raw HealthKit/HC samples into accepted server-side step credit, plus the reconciliation pass that downgrades or accepts provisional state.
Decision — end-to-end sequence
[1] DEVICE-SIDE SAMPLE COLLECTION (mobile-developer territory) Mobile reads HealthKit (iOS) / Health Connect (Android) once per foreground tick + once per OS background callback. Samples are bucketed by local-date (IANA TZ at sample time). Bucket carries: - day (ISO date in IANA TZ at sample time) - count (int >= 0) - source (HealthKit | HealthConnect | WatchNative) - tz (IANA TZ string at submission time) - sampleSpan (start..end UTC) - sourceBundleId (e.g. com.apple.health, com.google.android.apps.healthdata)
[2] DEVICE ATTESTATION TOKEN MINT Mobile requests a fresh Firebase App Check token: - iOS: DeviceCheck via App Attest - Android: Play Integrity API Token is opaque, signed, short-lived (~1h). Token MUST be minted within 5 minutes of /step/ingest call.
[3] CLIENT POST /step/ingest Mobile (online) → POST /step/ingest Body: { day, count, source, tz, sampleSpan, sourceBundleId, attestationToken, idempotencyKey, clientSubmittedAt } Headers: Authorization: Bearer <internal-session-JWT> X-Firebase-AppCheck: <attestation-token>
[4] CLOUD ARMOR + EDGE FILTERING DDoS, geo-anomaly, rate-limit (50 req/min/walker). Rejected requests do not reach Cloud Run.
[5] NESTJS /step/ingest HANDLER a. AppCheck token verification (Firebase Admin verifyToken). If invalid → 401 + AttestationLog row with verdict=REJECTED. b. Session JWT verification (NestJS-issued, KMS-signed). c. Idempotency check: idempotencyKey already seen for this walker → 200 with prior result (no double-credit). d. Sanity checks (anti-cheat layer — see ADR-0004): - count <= MAX_STEPS_PER_DAY (50_000 hard cap) - sampleSpan duration matches `count / MAX_STEP_RATE_PER_SEC` within 3x tolerance (burst-rate guard) - tz must match the tz reported in the prior 24h (TZ-jump guard) - day must not be > tomorrow in claimed tz - day must not be > 7 days in past (offline cap per D-009) - sourceBundleId must be in whitelist e. Write AttestationLog (token id + verdict + raw response, 30-day retention). f. Upsert StepLog (per walker × day): - reconciliationStatus = PENDING - acceptedCount = 0 (filled at [6]) - reportedCount = count - source, tz, sampleSpan recorded g. Enqueue Cloud Task "reconcile-steps" for this walker × day with 60s delay (lets multiple bursts coalesce). h. Compute provisional Energy credit for client UX: provisional = min(reportedCount, count_from_prior_log_for_today * 1.5) Return 200 with { provisional: true, provisionalEnergy, streakState }. streakState shows the streak as-if today is attested (computed live).
[6] RECONCILIATION WORKER (Cloud Tasks → walkrpg-jobs) Triggered 60s post-ingest, deduplicated per walker × day. Steps: a. Re-read StepLog for the day. If still PENDING: i. Mobile is asked (via push or next foreground call) to provide a fresh HealthKit/HC re-read for that day with sample digest. For closed beta, we trust the most-recent submission and only validate the *server-recorded* sequence — Phase 11 will add a "challenge re-read" handshake. ii. Run anti-cheat checks again with the full day's window. iii. Compare delta vs previous StepLog rows for the same day: if delta is monotonically increasing within source bundle, ACCEPT; if delta jumps backwards or duplicates timestamps from another source bundle (cross-source double-counting), QUARANTINE. iv. Update StreakState: - if accepted and count >= MIN_DAILY_THRESHOLD (2_000 steps, configurable): mark day attested, push streak tier per ADR-0003 rules. - if accepted but below threshold: day NOT attested, streak decay check fires. - if QUARANTINED: streak does not advance; AttestationLog carries verdict=QUARANTINED; admin review queue notified. b. Resolve any provisional TreeAllocation rows for this walker: - if the day is now attested AND the points the walker spent match accepted Energy credit: flip provisional=false. - if the day is rejected: provisional rows stay in place for a 24h grace period, then automatically refund the points (TreeAllocation row marked refunded, points returned to walker).
[7] OFFLINE PATH (no /step/ingest at the time of walk) Mobile queues sample buckets locally. On next online sync: - mobile submits multiple ingest calls (one per day in queue) - each carries day = local-date AT TIME OF WALK (NOT today) - server applies [5] for each - 7-day cap: any day > 7 days old is REJECTED with code OFFLINE_CAP_EXCEEDED (D-009 hard cap)Reject conditions (final list)
The server rejects a step ingest call (returns 4xx) — never silently swallows — under any of:
| Code | Condition | Action |
|---|---|---|
| 401 | App Check token invalid | AttestationLog REJECTED, no StepLog write |
| 401 | Session JWT invalid | no DB write |
| 403 | sourceBundleId not in whitelist | AttestationLog REJECTED, no StepLog write |
| 409 | Idempotency key already accepted | Return cached prior response |
| 422 | count exceeds MAX_STEPS_PER_DAY (50_000) | AttestationLog REJECTED, anti-cheat flag |
| 422 | burst rate >3x physiological limit | AttestationLog REJECTED, anti-cheat flag |
| 422 | TZ jump >12h since prior sync within 24h | AttestationLog REJECTED, anti-cheat flag |
| 422 | day > tomorrow in claimed tz | future-dated request, AttestationLog REJECTED |
| 422 | day > 7 days past | OFFLINE_CAP_EXCEEDED, AttestationLog REJECTED |
Rejections in the anti-cheat category accumulate per walker. The 5th rejection within 24h flags the walker for manual review queue (ADR-0004).
Storage model (Prisma — referenced from backend/prisma/schema.prisma)
The pipeline writes to four models:
- StepLog — per walker × day aggregate. Carries reportedCount, acceptedCount, reconciliationStatus, source, tz, sampleSpan.
- AttestationLog — per ingest call. Carries token id, verdict, response summary (30-day retention).
- StreakState — per walker. Carries currentLengthDays, lastAttestedDate, bonusTier, decayAt, totalLifetimeSteps.
- TreeAllocation + KeystoneAllocation — provisional flag toggled by reconciliation.
Provisional UX contract
Mobile MUST treat provisional: true responses as in-flight. UI shows step credit and Energy but tags allocations made under provisional credit with a “pending validation” badge. If reconciliation later refunds, the mobile receives a push (/walker/allocation-reverted event) and clears the allocation locally.
This is the contract the ui-designer (phase 9) builds the wireframe against.
Consequences
- Pipeline is one-way: client writes, server validates, server is authoritative. No client-side override.
- Reconciliation is async — provisional UX is the price of supporting D-009 heavy offline. UI must convey provisional state honestly to maintain trust.
- 7-day cap is firm. Players who go off-grid >7 days lose steps beyond day 7. This is by-design anti-mock-step exposure cap.
- Source bundle whitelist becomes an operational lever: as new HealthKit / Health Connect data sources emerge (e.g. third-party watch apps), tech-architect maintains the whitelist via config flag, no schema change required.