WP-03 — Dormancy Design.
Roughly 9 minutes.
How Lattice can sit on a phone for eighteen months without being opened, costing essentially nothing in battery, and still wake correctly when its user actually needs it. The four-state lifecycle, the wake conditions, and the empirical battery budget.
Authors: Lattice project · Version: 1.0 (draft) · License: CC-BY-4.0
1. The problem.
Lattice's whole product positioning is that it sits in the background until the day the internet doesn't work. That day might be tomorrow. It might be in 18 months. The standby tool that nobody opens for 18 months has to either be free in battery or it gets uninstalled the first time the user notices it eating their day.
WhatsApp gets a free pass on this question because Apple and Google's push-notification services do the work — the phone is woken by APNs / FCM only when there's a message to deliver. Lattice has no server, so this option doesn't exist. Lattice has to scan for nearby phones itself, and unbounded scanning would obliterate the battery model.
The solution is a state machine that throttles scanning aggressively the longer the app goes unused, while still ensuring real messages get through and the user can re-engage immediately.
2. Four states.
The lifecycle is orthogonal to the existing density-driven scan-rate state machine (Sparse / Normal / Dense / Crowd). Both are alive simultaneously: density determines how the radio behaves when scanning, lifecycle determines how often scanning happens.
┌──────────────────────────────────────────────────────────┐ │ │ │ ┌────────┐ 4h no use ┌─────────┐ 7d no use │ │ │ Active │ ────────────▶│ Standby │ ────────────┐ │ │ │ ≤ 4h │ │ 4h..7d │ │ │ │ └────────┘ ◀────── used│ │ ▼ │ │ ▲ any tick └─────────┘ ┌──────────┐ │ │ │ ▲ │ Dormant │ │ │ │ wake from any │ │ 7d..90d │ │ │ │ lower state └───────── │ │ │ │ │ used └──────────┘ │ │ │ │ │ │ └─── any wake ──────────────────────┐ │ 90d │ │ │ ▼ │ │ ┌─────────────┐ │ │ │ Hibernating │ │ │ │ 90d+ │ │ │ └─────────────┘ │ │ │ └──────────────────────────────────────────────────────────┘
2.1 Active.
App in foreground OR used in the last 4 hours. Full mesh participation, all transports, the existing density-driven scan plan applies unchanged.
2.2 Standby.
Used in the last 7 days but not the last 4 hours. Background scanning at a reduced rate — half the window, double the gap of the Active plan. Receipt works. Relay-duty (forwarding for others) still on.
2.3 Dormant.
Not used for 7 to 90 days. Heavily filtered: ~10% of the Active window, ~10× the gap. Concretely, with a typical Active window of 1500ms, Dormant is ~150ms scans. Relay-duty off — Dormant devices participate in the mesh only as endpoints (sending or receiving for themselves), not as relays. They won't forward packets for others.
Why no relay duty: relay forwarding is the largest battery cost. A Dormant device is, by user signal, not part of the active mesh. Absorbing other people's relay traffic on their behalf is unfair to the user who installed Lattice as a standby tool.
2.4 Hibernating.
Not used 90+ days. ~5% Active window, ~50× gap. Once-a-day-ish scan in practice. Relay-duty off. Indistinguishable from "app not running" in battery cost.
3. Wake conditions.
A Dormant or Hibernating device returns to Active on any of three conditions:
- The user opens the app. Foreground transition triggers an immediate state reset.
- A verified contact's BLE advertisement is observed during a dormant scan. The infrequent scan still happens; if it spots a known peer's beacon, it interprets that as "someone I care about is nearby and might be trying to reach me" and wakes.
- Density spike. A passive scan reveals far more Lattice peers than expected — suggestive of an event, a disaster gathering, or a festival. Wakes proactively because the user might want to participate.
Implementation in core/crates/lattice-power/src/lifecycle.rs::should_wake. Property tests verify that no other inputs cause a wake (no spurious wakes from unverified peers or random radio noise).
4. Battery budget.
Targets per state. These are ceilings, not aspirations; PRs that exceed them are reverted.
| Lifecycle | Daily ceiling | Measurement scenario |
|---|---|---|
| Active foreground | 2% / hour | 1h foreground use |
| Active background | 8% / 8h | backgrounded with 50 ambient peers, 20 messages exchanged |
| Standby | 4% / 24h | typical urban density, no active conversations |
| Dormant | 0.5% / 24h | same density, no activity |
| Hibernating | indistinguishable from "app not running" | idle phone, single daily scan |
Measured with the battery harness in tools/battery-harness/ using MetricKit on iOS (MXEnergyMetric) and dumpsys batterystats on Android. Each measurement scenario is reproducible from the same input log.
5. Edge cases and clock-manipulation.
5.1 Time-zone changes.
Wall-clock movements (DST, manual time-set, timezone travel) might appear to advance the "seconds since last use" counter discontinuously. The implementation uses seconds_since_last_use derived from the platform's monotonic clock plus a stored last-use timestamp in wall-clock time, choosing the more conservative of the two. A user who flies and changes their phone's timezone won't have their lifecycle change as a result.
5.2 OS sleep / phone-off.
The lifecycle is computed at each wake of the platform's background-scan task. If the phone has been off for two days, the next wake observes seconds_since_last_use ≥ 2 days and may transition Standby → Dormant accordingly. A phone that's been off for 91 days similarly transitions Dormant → Hibernating on first wake afterwards.
5.3 Recovery from bad lifecycle state.
If the lifecycle store is corrupted or somehow loses its last-use timestamp, the device defaults to Active. We'd rather the user pay 8% on their first day than miss a message. The UI will then transition correctly on the next idle interval.
6. Why a four-state model rather than a continuous scan-rate function.
We considered making the scan rate a continuous function of idle time — say, exponentially backing off the scan rate as days pass. We chose discrete states for three reasons:
- Predictability. "I'm in Standby" is something a user can read on a settings screen. "My scan rate is 0.0034 per minute" is not. The same applies to debugging: a small number of named states is straightforward to reason about.
- Hysteresis. A continuous function tends to oscillate near boundaries. Discrete states with explicit transition rules make it explicit when state changes happen.
- Honesty in copy. "Lattice is currently in Dormant mode — your battery cost is essentially zero" is a phrase a user can understand. The continuous-function equivalent isn't.
7. The 90-day acceptance test.
One of the M14 release-blocker tests (T-M14-08): a clock-manipulation simulation that fast-forwards a Lattice install through 90 days of idle, then sends it a message. The test asserts:
- Total simulated battery cost over the 90 days is under 1%.
- After 90 days, the device wakes on a verified contact's BLE advertisement and delivers the message.
- Identity is intact — the seed-derived keys produce the same Bullet ID.
- Contacts are intact.
- The app is fully functional after the wake — no orphaned state, no migration errors.
Failure of this test blocks v1.0. The standby positioning is a contract; long-term dormancy is the contract's primary claim.
8. References.
- RFC-0007 (internal) — Power Management. The base scan-rate state machine.
- WP-04 — Density and Crowd Behaviour. The orthogonal density-state model.
- WP-05 — What Lattice Doesn't Do. Where dormancy's limits sit.
- Apple
MXEnergyMetric— measurement source on iOS. - Android
dumpsys batterystats— measurement source on Android.