// Retention floors, and the clock-jump guard. // // THE BUG THIS FILE IS SHAPED BY. No cursor and no ordering in this engine // depends on the device clock - but the retention BOUNDARY does, so a large clock // skew moves the window. The mobile client added a guard: if the computed floor // moves more than ~25 h from the last one, keep the previous floor and warn. // // The guard then wiped the entire offline store. Mechanism, exactly: // // 1. clock jumps forward a year // 2. cycle N computes a floor a year ahead, detects the >25 h move, holds the // old floor for this cycle - and PERSISTS THE COMPUTED (jumped) FLOOR as // `lastWindowFloor` // 3. the follow-on cycle a few seconds later computes a floor within seconds of // the persisted one, so `delta <= threshold`, so the guard passes // 4. that floor is classified as a retention NARROW, and every envelope below // it is evicted - i.e. all of them, since the floor is a year in the future // 5. `coveredFrom` then claims the range complete, and `/changes` cannot // re-deliver pre-existing mail. Unrecoverable. // // A clock glitch wiped the store about five seconds after being detected, // THROUGH the very mechanism meant to prevent that. Its reproduction returned 0 // envelopes from a 4-envelope store. // // The generalisable lesson, worth more than the code: a guard that DETECTS an // anomaly but PERSISTS the anomalous value converts a transient glitch into a // legitimised new baseline. Any "suppress and remember" guard must remember the // value it USED, not the value it rejected - and must expose a separate // "don't delete anything on this basis" bit, because suppressing the floor is not // the same as suppressing the deletions the floor authorises. import type { RetentionPolicy } from './store'; /** A day plus an hour: an ordinary DST shift or NTP correction must not trip it. */ export const CLOCK_JUMP_GUARD_MS = 25 * 60 * 60 * 1000; export interface RetentionFloors { envelopeFrom: string; bodyFrom: string; maxBodyBytes: number; } function isoDaysAgo(now: number, days: number): string { return new Date(now - days * 24 * 60 * 60 * 1000).toISOString(); } export function computeFloors(policy: RetentionPolicy, now: number): RetentionFloors { // The body window can never be wider than the envelope window: a body with no // envelope is an orphan by construction, and the whole point of two tiers is // envelopes being a superset of bodies. const bodyDays = Math.min(policy.bodyDays, policy.envelopeDays); return { envelopeFrom: isoDaysAgo(now, policy.envelopeDays), bodyFrom: isoDaysAgo(now, bodyDays), maxBodyBytes: Math.max(0, Math.floor(policy.maxBodyMB * 1024 * 1024)), }; } export interface GuardedFloor { /** The floor to actually use for eviction and for any sweep. */ envelopeFrom: string; /** True when the guard suppressed a suspicious jump. */ suppressed: boolean; /** * False while the floor in use came from a SUSPECT clock reading. Retention * eviction and the reconcile sweep must BOTH refuse to act on it - suppressing * the floor is not the same as suppressing the deletions it authorises. */ evictionAllowed: boolean; /** What to persist as `lastWindowFloor`. */ nextLastWindowFloor: string; warning?: string; } export function guardFloorAgainstClockJump( computed: string, lastWindowFloor: string | undefined, opts: { policyChanged?: boolean } = {}, ): GuardedFloor { const adopt = (floor: string): GuardedFloor => ({ envelopeFrom: floor, suppressed: false, evictionAllowed: true, nextLastWindowFloor: floor, }); if (!lastWindowFloor) return adopt(computed); // An explicit `envelopeDays` change is INTENT, not a glitch, and must take // effect - including its eviction. This is also why "adopt on the second // consistent observation" had to go: with intent handled here, that rule // existed ONLY for the clock-anomaly case, i.e. only for the case where // adopting is the harmful thing to do. if (opts.policyChanged) return adopt(computed); const delta = Math.abs(Date.parse(computed) - Date.parse(lastWindowFloor)); if (!Number.isFinite(delta) || delta <= CLOCK_JUMP_GUARD_MS) return adopt(computed); // SUSPECT. Ignore the reading entirely, keep the previous floor, and persist // THE PREVIOUS FLOOR - so every subsequent cycle re-detects the same jump and // stays suppressed. Persisting the computed value here is the wipe described // at the top of this file. // // The trade-off, stated rather than hidden: on a device whose clock is // permanently wrong by more than a day, retention stops tracking the clock and // the store keeps MORE mail than the setting says. That is bounded (envelopes // are ~1 KB, bodies are capped in bytes) and self-clears the moment the user // changes a retention setting or the clock returns. Keeping too much mail is // the correct direction to fail for a feature whose entire purpose is having // mail available offline. return { envelopeFrom: lastWindowFloor, suppressed: true, evictionAllowed: false, nextLastWindowFloor: lastWindowFloor, warning: `retention floor moved ${Math.round(delta / 3_600_000)}h in one step ` + `(${lastWindowFloor} -> ${computed}); treating it as a clock anomaly and ` + `refusing to evict or sweep on this basis`, }; } export type FloorMovement = 'unchanged' | 'widened' | 'narrowed'; export function floorMovement(previous: string | undefined, next: string): FloorMovement { if (!previous) return 'unchanged'; if (next === previous) return 'unchanged'; // A LATER floor keeps less mail. return next > previous ? 'narrowed' : 'widened'; } export interface WindowAdjustment { /** Set when envelopes below this floor should be evicted. */ evictBelow?: string; /** Set when coverage must re-scan from a wider floor. */ rescanFrom?: string; } /** * What a floor movement asks for. * * A WIDEN is not a resync: `targetFrom` moves back, coverage re-enters * `scanning`, and the cursors are untouched. A NARROW evicts. */ export function adjustForWindow(movement: FloorMovement, floor: string): WindowAdjustment { if (movement === 'narrowed') return { evictBelow: floor }; if (movement === 'widened') return { rescanFrom: floor }; return {}; }