Skip to content

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:

CodeConditionAction
401App Check token invalidAttestationLog REJECTED, no StepLog write
401Session JWT invalidno DB write
403sourceBundleId not in whitelistAttestationLog REJECTED, no StepLog write
409Idempotency key already acceptedReturn cached prior response
422count exceeds MAX_STEPS_PER_DAY (50_000)AttestationLog REJECTED, anti-cheat flag
422burst rate >3x physiological limitAttestationLog REJECTED, anti-cheat flag
422TZ jump >12h since prior sync within 24hAttestationLog REJECTED, anti-cheat flag
422day > tomorrow in claimed tzfuture-dated request, AttestationLog REJECTED
422day > 7 days pastOFFLINE_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.