ADR-0003 — Streak attestation algorithm
ADR-0003 — Streak attestation algorithm (Pillar 1 protection)
Status: draft Date: 2026-05-18 Owner: tech-architect (with mechanics-designer review at phase 11) Related canon: D-007 (streak step economy + per-day attestation), D-009 (heavy offline + 7d cap) Pillar: 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 — streak computes from accepted StepLog rows in trust-mode (no App Check token check, no reconciliation gating), and the bonus tier table + decay state machine still apply. The attestation check (Layer 1) and the backfill rule under heavy offline are deferred to the production-migration phase, currently paused.
Context
D-007 ratified a streak-based step economy: base 1:1 step→Energy + bonus (+20% at 7 days, +50% at 30 days) + decay on missed day. The system-reviewer note on D-007 forced a clarification: streak decays on missed attested days, not on missed sync days. This protects Pillar 1 against mock-step apps and pure-idle progression while still allowing D-009 heavy offline play.
This ADR fixes the exact algorithm so mechanics-designer balance numbers and backend reconciliation logic line up.
Decision — what counts as an “attested day”
A day D (in the walker’s IANA tz at the time of the walk) is attested when ALL of the following are true after reconciliation:
- Source check.
acceptedCountfor dayDfrom a HealthKit / Health Connect / WatchNative source bundle in the whitelist is>= MIN_DAILY_THRESHOLD(default 2_000 authenticated steps; tunable per region by mechanics-designer at phase 11). - Attestation check. At least one Firebase App Check token was validated within the calendar boundaries of day
Din the walker’s reported tz. (Token mint time is checked; in heavy-offline path, tokens minted during the day and queued for later submission satisfy this — but the token’s own minting timestamp must fall withinD.) - Reconciliation check.
StepLog.reconciliationStatus = ACCEPTED(not PENDING, not QUARANTINED, not REJECTED).
If any one fails: day D is NOT attested.
Decision — streak state machine
StreakState carries five fields:
| Field | Type | Notes |
|---|---|---|
currentLengthDays | int | consecutive attested days ending at lastAttestedDate |
lastAttestedDate | ISO date | most recent attested day |
bonusTier | enum (NONE, T1_7D, T2_30D) | derived from currentLengthDays |
decayAt | ISO date | the day on which currentLengthDays will drop to 0 if no new attestation lands |
totalLifetimeSteps | int | running sum of acceptedCount across all StepLogs (for region step-gating per data/schemas/region.ts) |
Bonus tier table
| Tier | currentLengthDays | Energy multiplier |
|---|---|---|
| NONE | 0..6 | 1.00× |
| T1_7D | 7..29 | 1.20× |
| T2_30D | 30+ | 1.50× |
Multiplier applies to acceptedCount when converting to Energy on the day the attestation lands. Provisional Energy in the /step/ingest response shows the tier the walker WOULD be on if today gets attested; final Energy credit is committed at reconciliation.
Transitions
Trigger: reconciliation marks day D as accepted/rejected.
on attestation(D, ACCEPTED): if D == lastAttestedDate + 1 day: currentLengthDays += 1 lastAttestedDate = D elif D == lastAttestedDate: no-op (same day, multiple ingests) elif D > lastAttestedDate + 1 day: # Gap! Walker missed at least one calendar day between lastAttestedDate and D. # Streak resets to 1 (THIS day is attested, prior chain is broken). currentLengthDays = 1 lastAttestedDate = D elif D < lastAttestedDate: # Backfill attestation for an earlier day arrived (heavy offline). # Recompute streak from scratch using all StepLog rows for this walker # ordered by day ascending. See "Backfill rule" below. recomputeStreakFromScratch(walker) bonusTier = tierForLength(currentLengthDays) decayAt = D + 1 day
on attestation(D, REJECTED | QUARANTINED): # Streak does NOT advance. It does not reset YET — decayAt drives the reset. no streak mutation
on daily-decay-sweep (Cloud Scheduler, 00:01 UTC): for each walker where today > decayAt: currentLengthDays = 0 lastAttestedDate = unchanged bonusTier = NONE decayAt = today # idempotentBackfill rule (handles heavy offline)
When a walker comes online after several days offline and submits multiple days at once, reconciliation runs recomputeStreakFromScratch:
- Pull all StepLog rows for the walker where
reconciliationStatus = ACCEPTED, ordered bydayascending. - Walk forward, counting consecutive attested days. A gap of >=1 missing-attested day breaks the chain.
- The final consecutive run ending at the latest attested day becomes
currentLengthDays,lastAttestedDate= that day.
This is O(N) in StepLog rows for the walker; partitioned by walkerId; runs only on backfill. Performance budget at Band C: under 50ms p99 (Postgres index on (walkerId, day)).
Anti-exploit cases (the system-reviewer asked to be killed)
Exploit 1 — Binge walk one day + idle 6 within the 7-day cap
Walker walks 50_000 steps on day D, then sits motionless 6 days (no walking). D-009 7-day offline cap doesn’t reject (it allows up to 7 days). What happens?
Under this algorithm:
- Day D: 50_000 accepted (subject to burst-rate guard). Day D attested.
currentLengthDays = 1.bonusTier = NONE. - Days D+1..D+6: no steps recorded. Day NOT attested (fails check 1, fails check 2).
decayAtwas set to D+1. On the daily-decay-sweep of day D+2, walker’scurrentLengthDays = 0. - Day D+7 (walker walks again): if attested,
currentLengthDays = 1(gap broke streak).
Outcome: binge walking does NOT accumulate streak bonus. The walker gets 50_000 × 1.00 Energy from day D and that’s it. Streak demands consecutive attested days — there is no shortcut.
Exploit 2 — Mock-step app submits attestation-less counts
Mock-step apps cannot produce a valid Play Integrity / DeviceCheck token (the token is bound to a genuine OS-signed device). AppCheck rejection → StepLog rejected → day not attested → streak no-op. ADR-0004 covers the deeper anti-cheat layer.
Exploit 3 — TZ manipulation to extend a day
Walker tries to “extend” a day by jumping back across midnight. ADR-0002 reject condition #7 catches the TZ jump (>12h tz shift within 24h). AppCheck verdict REJECTED → no attestation → day not attested.
Exploit 4 — Tiny-walks-every-day farming
Walker walks exactly 2_000 steps every day for 30 days to lock the T2_30D 1.5× bonus, then binge-walks 50_000 steps on day 31 to maximize Energy.
This is intended behaviour, not an exploit. The streak rewards consistent daily walking with a Energy multiplier; that’s the explicit Pillar 1 incentive. mechanics-designer can raise the MIN_DAILY_THRESHOLD (e.g. to 5_000) if 2_000 feels too easy, but the rule is value-neutral — it just defines “did the walker move today”.
Exploit 5 — Multi-device farming
Walker has two phones with two HealthKit accounts both syncing to the same walker. ADR-0002 cross-source double-counting guard catches this: if two source bundles report the same timestamp ranges, reconciliation QUARANTINEs the duplicate. Walker can use multiple devices but cannot double-count.
Configuration knobs
mechanics-designer owns these values at phase 11; tech-architect ships defaults.
| Key | Default | Range | Notes |
|---|---|---|---|
MIN_DAILY_THRESHOLD | 2_000 | 1_000..10_000 | Steps needed for “attested” verdict |
STREAK_T1_DAYS | 7 | 5..14 | Days for T1_7D tier |
STREAK_T2_DAYS | 30 | 21..60 | Days for T2_30D tier |
STREAK_T1_MULT | 1.20 | 1.05..1.50 | T1 Energy multiplier |
STREAK_T2_MULT | 1.50 | 1.10..2.00 | T2 Energy multiplier |
STREAK_DECAY_GRACE_HOURS | 0 | 0..24 | Grace period before decayAt fires |
Defaults conform to D-007 numbers exactly. mechanics-designer may amend in phase 11 via B-level decision log.
Consequences
- Streak is the canonical Pillar 1 incentive. Anti-cheat (ADR-0004) is the gate; this is the carrot.
- Heavy offline (D-009) works only if the walker actually walked offline. Binge-walking and idling does not survive the consecutive-attested-day rule.
- Algorithm is deterministic and idempotent on reconciliation — re-running reconciliation produces the same streak state. Safe to retry.
- mechanics-designer has clear tuning surface without schema churn.