Skip to content

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:

  1. Source check. acceptedCount for day D from 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).
  2. Attestation check. At least one Firebase App Check token was validated within the calendar boundaries of day D in 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 within D.)
  3. 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:

FieldTypeNotes
currentLengthDaysintconsecutive attested days ending at lastAttestedDate
lastAttestedDateISO datemost recent attested day
bonusTierenum (NONE, T1_7D, T2_30D)derived from currentLengthDays
decayAtISO datethe day on which currentLengthDays will drop to 0 if no new attestation lands
totalLifetimeStepsintrunning sum of acceptedCount across all StepLogs (for region step-gating per data/schemas/region.ts)

Bonus tier table

TiercurrentLengthDaysEnergy multiplier
NONE0..61.00×
T1_7D7..291.20×
T2_30D30+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 # idempotent

Backfill rule (handles heavy offline)

When a walker comes online after several days offline and submits multiple days at once, reconciliation runs recomputeStreakFromScratch:

  1. Pull all StepLog rows for the walker where reconciliationStatus = ACCEPTED, ordered by day ascending.
  2. Walk forward, counting consecutive attested days. A gap of >=1 missing-attested day breaks the chain.
  3. 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). decayAt was set to D+1. On the daily-decay-sweep of day D+2, walker’s currentLengthDays = 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.

KeyDefaultRangeNotes
MIN_DAILY_THRESHOLD2_0001_000..10_000Steps needed for “attested” verdict
STREAK_T1_DAYS75..14Days for T1_7D tier
STREAK_T2_DAYS3021..60Days for T2_30D tier
STREAK_T1_MULT1.201.05..1.50T1 Energy multiplier
STREAK_T2_MULT1.501.10..2.00T2 Energy multiplier
STREAK_DECAY_GRACE_HOURS00..24Grace 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.