v4.22 — Widget Background Check-In Sync
A habit check-in tapped on the home-screen widget now reaches the server even if you never open the app — closing the streak-loss window for good, on both Android and iOS.
Summary
The widget's interactive check-off has been fully built and symmetric across both platforms since v4.7 (iOS) and v4.11 (Android) — tap a habit on the widget and it checks off instantly, locally. But delivery to the server was entirely foreground-driven: the pending-check-in queue only drained when the app was next opened (useWidgetCheckinDrain.ts, on mount/resume). A user who checked in from the widget and never reopened the app that day lost the streak, no matter how the tap was recorded locally. Slice A of this problem (OBJ-1858, PR #1851) had already fixed which day a late-synced check-in credits — the real local tap date, not the sync date — but only within the existing streak-grace window, and only once the app was eventually reopened.
v4.22 is Slice B: it removes the app-open dependency entirely. Phase 1 was a deliberate decision gate rather than a start on implementation, because two credible approaches existed and they diverge at the auth layer. Codi's spike found the lower-infrastructure option — a Capacitor background-runner reusing the existing Clerk session — an architectural dead end: its headless JS context can't reach the secure-storage plugin holding the Clerk refresh token, so it can't avoid shipping a new on-device credential anyway, and its only workaround would have landed one in unencrypted storage. Josh signed off on the alternative: a durable, device-scoped, check-in-write-only CheckinToken, minted server-side and stored in Keychain/Keystore, with two amendments to the draft shape — long-lived (~90 days) with rotate-on-use rather than hours-lived, since the whole point of the widget is checking in for days without opening the app.
Phases 2 through 5 built to that locked shape: the backend token model and a new CheckinToken auth branch (Phase 2), app-side minting and a native secure-storage bridge (Phase 3), Android WorkManager delivery (Phase 4), and iOS BGTaskScheduler delivery parity plus on-device verification and this milestone's documentation (Phase 5). Both platforms ship the same contract — post at tap time as the primary path, a background scheduler job as retry-only backstop, a rejected or expired token as a hard stop-and-clear rather than a silent retry, and no new deduplication authority beyond the server's existing per-day idempotency. Two real review-round findings — an offline-sign-out stale-token security gap and a device-id backfill gap that would have silently disabled background delivery on existing devices — were caught and fixed before merge, not after.
One risk was accepted rather than resolved: whether iOS's BGTaskScheduler genuinely invokes its registered retry handler while the app is fully closed could not be proven in this environment, for lack of a physical iOS device. Josh explicitly accepted shipping on the strength of the tap-time-primary path — which is structurally guaranteed by iOS's own App-Extension sandboxing and carries the real reliability — choosing to monitor the scheduler-retry path's real-world behavior via production telemetry after launch instead of blocking on unavailable hardware.
Goal
A habit check-in tapped on the home-screen widget reaches the server without the user ever opening the app — closing the streak-loss window entirely, on both Android and iOS.
Scope — What Shipped
CheckinTokenmodel + auth branch (rails_api/app/models/checkin_token.rb,graphql_controller.rb#current_user) — aPublicRecord+public_id+acts_as_paranoidDB-backed credential, check-in-write-scoped only, device-bound, ~90-day TTL, server-revocable. A newSessionToken: CheckinToken <deviceId>:<token>header scheme sits alongside the existing Clerk JWT,MagicLink, andFallbackbranches.- Mint / rotate-on-use / revoke
Interaction::Baseclasses (UserIdentity::MintCheckinToken/RotateCheckinToken/RevokeCheckinToken) — mint on sign-in behindwidget_background_sync_enabled(fail-closed: returnsnilwhen the flag is off), rotate-on-use on every successful background-authenticatedcheckInHabit, revoke bulk-clears all of a device's tokens on sign-out. useCheckinToken.ts+ native bridge push — mints/caches the token app-side, pushes it to iOS Keychain / Android Keystore-backedEncryptedSharedPreferencesvia newWidgetBridge.setCheckinToken/clearCheckinTokenmethods, always re-pushes on an already-cached token so a pre-Phase-4 token gets its device id backfilled.- Android background delivery —
HomeWidgetProvider.onReceiveenqueues an expeditedWorkManagerjob at tap (primary); a periodicWorkManagerjob (CheckinDeliveryWorker,androidx.work:work-runtime:2.10.0) drainsPENDING_CHECKINS_LOCKas retry-only. Verified on real hardware: a Pixel emulator,am force-stopfull kill,dumpsys jobscheduler+ logcat proof of genuine post-kill execution. - iOS background delivery —
CheckInHabitIntent.perform()POSTs at tap time inside the widget-extension process (a guaranteed live window by construction of App Extension architecture); aBGTaskScheduler-registeredCheckinDeliveryTaskdrains the App Group queue as retry-only. NewInfo.plistkeys (BGTaskSchedulerPermittedIdentifiers,UIBackgroundModes), no new entitlement. - Shared delivery contract, both platforms —
CheckinToken <deviceId>:<token>header, remove-only-sent on drain, hard stop-and-clear (never a silent retry) on a rejected/expired token, honour the existingPENDING_CHECKINS_LOCK/App Group lock, reuse the existingcheckInHabitmutation and the server's per-day idempotency — no second dedup authority anywhere in the stack. widget_background_sync_enabledfeature flag — server-side only (PostHog, evaluated inside the mint interaction). Flag-off is byte-identical to pre-milestone behavior on both platforms: no token minted, no background job registered, tap-time delivery silently no-ops back to the existing foreground drain.- Docs —
docs/features/home-widgets.md(Background check-in delivery section: architecture, both delivery paths, idempotency, flag, the accepted iOS risk),docs/architecture/authentication.md(Check-in Token Authentication: header format, mint/rotate/revoke lifecycle, device binding),docs/operations/mobile-builds.md(WorkManager/BGTaskScheduler build setup, newInfo.plistkeys, GraphQL endpoint resolution for native).
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Architecture decision gate | Shipped | 1 | Spiked and rejected the Capacitor background-runner option (can't reach the Clerk refresh token); locked the device-scoped CheckinToken approach with Josh's TTL/device-binding amendments |
| 2 | Auth foundation | Shipped | 1 | CheckinToken model, CheckinToken <token> auth branch, mint/rotate/revoke Interactions, fail-closed behind the flag |
| 3 | App-side credential mint/refresh + native bridge push | Shipped | 1 | useCheckinToken.ts, Keychain/Keystore storage, WidgetBridge push; fixed a security BLOCKER (offline-sign-out stale-token reuse) in review |
| 4 | Android WorkManager background delivery | Shipped | 1 | Expedited tap-time job + periodic retry job; fixed a MAJOR (device-id backfill) in review; verified on real hardware |
| 5 | iOS delivery + verification + milestone docs | Shipped | 1 | BGTaskScheduler retry parity, on-device verification (accepted risk on live-fire proof), milestone-close docs, merged via the milestone-close integration PR |
Key Decisions
- Reject the Capacitor background-runner, adopt a durable device-scoped token — the spike collapsed what looked like a security-vs-reliability tradeoff: the background-runner's headless context genuinely cannot reach the Clerk refresh token, so it can't deliver its "no new credential" premise either. There was no real choice left once that was proven.
- ~90-day TTL with rotate-on-use, not hours-lived (Josh amendment) — an hours-lived token expires in exactly the multi-day-no-app-open scenario this milestone exists to fix, and re-minting needs a foreground session, so the failure would be silent and correlated with the users who need background delivery most.
- Tap-time-primary, scheduler-retry-only, on both platforms (Josh, at the decision gate) — designs around iOS's known BGTask unreliability tail up front rather than discovering it at milestone close; the widget-extension process gives iOS a genuinely live execution window at tap time that doesn't depend on OS scheduler discretion.
- No new dedup authority — reuse the server's per-day idempotency — the v4.11 lesson (an append landing between a drain's read and its clear was silently wiped) is honored here by keeping remove-only-sent semantics and never building a second source of truth for "was this check-in already recorded."
- A rejected or expired token is a hard stop-and-clear, never a silent retry — carried forward from the Phase 3 offline-sign-out security fix into both platforms' native delivery code, so a revoked credential can't be quietly retried with itself.
- Accept the iOS live-fire verification gap and ship (Josh, 2026-07-29) — the tap-time-primary path is structurally guaranteed and independently corroborated; the scheduler-retry path's actual OS-invocation is unprovable without a physical device the crew didn't have. Monitor via production telemetry post-launch rather than block on unavailable hardware.
- Phase 4/5 split by platform, not by delivery-vs-verify — reconciled mid-milestone to match the existing per-platform Multica child issues; the tap-time-primary + scheduler-retry architecture is unchanged, it's applied once per platform instead of once across both.
Requirements Coverage
5 / 5 phase deliverables satisfied (per the Milestone Audit; this milestone has no standalone REQUIREMENTS.md — coverage is derived from the ROADMAP's own phase checklist and fidelity contract per this repo's planning convention).
| Category | Count | Status |
|---|---|---|
| Decision gate + ADR (Phase 1) | 1 | Satisfied |
| Backend auth foundation (Phase 2) | 1 | Satisfied |
| App-side credential bridge (Phase 3) | 1 | Satisfied |
| Android background delivery (Phase 4) | 1 | Satisfied |
| iOS delivery + verification + docs (Phase 5) | 1 | Satisfied — with one accepted, documented risk on live-fire verification |
Outcomes
A habit check-in tapped from the home-screen widget on Android now reliably reaches the server even if the app is never reopened — proven on real hardware via a full app kill and OS-level JobScheduler verification. On iOS, the same tap-time path runs inside the widget-extension process's own guaranteed live window, so the check-in reaches the server before the background scheduler is ever needed in the common case; the scheduler-retry backstop's live-fire behavior while the app is fully closed remains unverified pending physical-device access and production telemetry. Both platforms fail closed: with widget_background_sync_enabled off, behavior is byte-identical to before this milestone. No production rollout percentage has been set yet — that remains a separate, later product decision, consistent with how prior widget milestones (v4.11) treated rollout.
Tech Debt
- (Phase 5, accepted risk) iOS
BGTaskSchedulerscheduler-retry live-fire behavior is unverified pre-launch — Josh accepted shipping on the tap-time-primary path's structural guarantee, to be monitored via production telemetry. - (Phase 5, open follow-up) No delivery success/failure telemetry breadcrumb exists yet in iOS
CheckinDeliveryTaskScheduling.performDelivery()or AndroidCheckinDeliveryWorker.doWork()— this is the actual mechanism the accepted-risk decision depends on, and Vicki flagged it as a tracked follow-up rather than a passing mention. - (carried, pre-existing) Neither platform's
GRAPHQL_BASE_URLoverride is wired into a CI workflow — every CI-built mobile artifact points at production unless set by hand. Not introduced by this milestone.
Related Artifacts
- Roadmap: v4.22-widget-background-checkin-sync-ROADMAP.md
- Milestone Audit: v4.22-widget-background-checkin-sync-MILESTONE-AUDIT.md
- Feature docs: docs/features/home-widgets.md § Background check-in delivery (v4.22)
- Auth docs: docs/architecture/authentication.md § Check-in Token Authentication
- Build docs: docs/operations/mobile-builds.md § Background check-in delivery build setup
- Merge PR (milestone-close): #1866
Related Commits
aa61589d3— [Riley] chore(release): merge v4.22 Widget Background Check-In Sync to master (OBJ-1860) (#1866) — milestone-complete commitdc2b3be6b— [Dori] docs(widget): document v4.22 background check-in delivery + CheckinToken lifecycle (OBJ-1865)039bcaa29— [Codi] feat(widget): Android WorkManager background check-in delivery (OBJ-1864) (#1859)5adee1cc0— [Codi] feat(auth): app-side check-in token mint/refresh + native bridge push (OBJ-1863) (#1857)a82f54831— [Codi] feat(auth): device-scoped check-in token foundation (OBJ-1862) (#1855)
Last updated: 2026-07-30