Skip to main content

max / goingson

Data & Sync remediation: blob integrity, DST recurrence, HLC conflict ordering Ultra-fuzz Run #28 Phase 1. GO-local fixes plus the GoingsOn side of the SyncKit HLC protocol. - Verify downloaded blobs against their content hash before committing them to the content-addressed store (was trusted on the read path). - Recurrence advances in the user's local time zone, so a fixed local time-of-day survives DST transitions (UTC wrappers retained; new tz-aware variants). - Restore weekly-review vacation_days verbatim (was silently dropping values >6). - HLC integration: migration 054 (hlc_state + per-changelog HLC stamps), a clock module that stamps local changes and advances on pull, and push/pull wiring so resolve_lww compares real clocks. hlc_state excluded from backup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 19:28 UTC
Commit: 5d39af14c1d087c87a05723366c7db450332e5b8
Parent: ab7f844
19 files changed, +414 insertions, -75 deletions
M Cargo.lock +2
@@ -1880,6 +1880,7 @@ version = "0.4.0"
1880 1880 dependencies = [
1881 1881 "async-trait",
1882 1882 "chrono",
1883 + "chrono-tz",
1883 1884 "serde",
1884 1885 "serde_json",
1885 1886 "sha2",
@@ -1922,6 +1923,7 @@ dependencies = [
1922 1923 "goingson-core",
1923 1924 "goingson-db-sqlite",
1924 1925 "goingson-plugin-runtime",
1926 + "iana-time-zone",
1925 1927 "ical",
1926 1928 "icalendar",
1927 1929 "keyring",
@@ -8,6 +8,7 @@ sqlx-sqlite = ["dep:sqlx"]
8 8
9 9 [dependencies]
10 10 chrono = { workspace = true }
11 + chrono-tz = { workspace = true }
11 12 uuid = { workspace = true }
12 13 serde = { workspace = true }
13 14 serde_json = { workspace = true }
@@ -80,7 +80,7 @@ pub use models::{
80 80 pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult};
81 81 pub use date_parser::parse_natural_date;
82 82 pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
83 - pub use recurrence::{calculate_next_due, calculate_next_due_with_day, calculate_next_due_rich, expand_recurrence, should_recur};
83 + pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur};
84 84 pub use repository::*;
85 85 pub use urgency::calculate_urgency;
86 86 pub use plugin::{
@@ -1,32 +1,56 @@
1 1 //! Recurring task and event scheduling logic.
2 -
3 - use chrono::{DateTime, Datelike, Duration, TimeZone, Timelike, Utc};
2 + //!
3 + //! Recurrence preserves the **local wall-clock time-of-day** in a target time
4 + //! zone, not the UTC instant. A task set to recur "every day at 09:00" must stay
5 + //! at 09:00 local across a daylight-saving transition; doing the arithmetic in
6 + //! UTC would drift it by an hour twice a year (ultra-fuzz Run #28 MINOR). Every
7 + //! computation therefore takes a `chrono_tz::Tz`: field extraction and result
8 + //! construction happen in that zone, and only the boundaries convert to/from UTC.
9 + //!
10 + //! The `*_in_tz` functions are canonical. The legacy non-`tz` wrappers pass
11 + //! `Tz::UTC` (no DST), preserving their original behavior for callers and tests
12 + //! that operate purely in UTC; application code resolves the system zone and
13 + //! calls the `*_in_tz` variants.
14 +
15 + use chrono::{DateTime, Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc};
16 + use chrono_tz::Tz;
4 17 use uuid::Uuid;
5 18 use crate::models::{Event, Recurrence, RecurrenceRule, MonthlySpec};
6 19
7 - /// Calculate the next due date based on recurrence type.
20 + /// Calculate the next due date based on recurrence type (UTC; no DST handling).
21 + ///
22 + /// Thin wrapper over [`calculate_next_due_in_tz`] with `Tz::UTC`. Application
23 + /// code should call the `_in_tz` form with the user's zone so a fixed local
24 + /// time-of-day survives DST transitions.
25 + pub fn calculate_next_due(
26 + current_due: Option<&DateTime<Utc>>,
27 + recurrence: &Recurrence,
28 + ) -> Option<DateTime<Utc>> {
29 + calculate_next_due_in_tz(current_due, recurrence, Tz::UTC)
30 + }
31 +
32 + /// Calculate the next due date based on recurrence type, in `tz`.
8 33 ///
9 34 /// The next occurrence is calculated from the original due date,
10 35 /// not from when the task was completed. This ensures consistent
11 - /// scheduling (e.g., "every Monday" stays on Mondays).
12 - ///
13 - /// For monthly recurrence, `original_day` can specify the intended
14 - /// day-of-month to prevent drift when months have fewer days
15 - /// (e.g., Jan 31 -> Feb 28 -> Mar 31 instead of Mar 28).
16 - pub fn calculate_next_due(
36 + /// scheduling (e.g., "every Monday" stays on Mondays) and keeps the
37 + /// local time-of-day stable across DST.
38 + pub fn calculate_next_due_in_tz(
17 39 current_due: Option<&DateTime<Utc>>,
18 40 recurrence: &Recurrence,
41 + tz: Tz,
19 42 ) -> Option<DateTime<Utc>> {
20 43 // For monthly recurrence, detect end-of-month dates and preserve intent.
21 44 // If the due date falls on the last day of its month AND the day is >= 29,
22 45 // treat the target as day 31 so it snaps to end-of-month in longer months.
23 46 // The >= 29 guard prevents false positives: Feb 28 in a non-leap year is
24 47 // ambiguous (user may have meant "the 28th"), but days 29-30 at month-end
25 - // clearly indicate end-of-month intent.
48 + // clearly indicate end-of-month intent. Day/month are read in `tz`.
26 49 let target_day = if matches!(recurrence, Recurrence::Monthly) {
27 50 current_due.and_then(|dt| {
28 - let day = dt.day();
29 - let month_len = days_in_month(dt.year(), dt.month());
51 + let local = dt.with_timezone(&tz);
52 + let day = local.day();
53 + let month_len = days_in_month(local.year(), local.month());
30 54 if day == month_len && day >= 29 && day < 31 {
31 55 Some(31)
32 56 } else {
@@ -36,30 +60,56 @@ pub fn calculate_next_due(
36 60 } else {
37 61 None
38 62 };
39 - calculate_next_due_with_day(current_due, recurrence, target_day)
63 + calculate_next_due_with_day_in_tz(current_due, recurrence, target_day, tz)
40 64 }
41 65
42 66 /// Like `calculate_next_due` but accepts an explicit target day-of-month
43 - /// for monthly recurrence to prevent day drift across short months.
67 + /// for monthly recurrence to prevent day drift across short months (UTC wrapper).
44 68 pub fn calculate_next_due_with_day(
45 69 current_due: Option<&DateTime<Utc>>,
46 70 recurrence: &Recurrence,
47 71 original_day: Option<u32>,
48 72 ) -> Option<DateTime<Utc>> {
73 + calculate_next_due_with_day_in_tz(current_due, recurrence, original_day, Tz::UTC)
74 + }
75 +
76 + /// `calculate_next_due_with_day` evaluated in `tz`. Daily/weekly advance by whole
77 + /// civil days in the zone (so the local time-of-day is stable across DST), not by
78 + /// fixed 24h/168h UTC spans.
79 + pub fn calculate_next_due_with_day_in_tz(
80 + current_due: Option<&DateTime<Utc>>,
81 + recurrence: &Recurrence,
82 + original_day: Option<u32>,
83 + tz: Tz,
84 + ) -> Option<DateTime<Utc>> {
49 85 let base_date = current_due.copied().unwrap_or_else(Utc::now);
50 86
51 87 match recurrence {
52 - Recurrence::Daily => Some(base_date + Duration::days(1)),
53 - Recurrence::Weekly => Some(base_date + Duration::weeks(1)),
88 + Recurrence::Daily => Some(add_civil_days(base_date, 1, tz)),
89 + Recurrence::Weekly => Some(add_civil_days(base_date, 7, tz)),
54 90 Recurrence::Monthly => {
55 - let next = add_months(base_date, 1, original_day);
91 + let next = add_months(base_date, 1, original_day, tz);
56 92 Some(next)
57 93 }
58 94 Recurrence::None => None,
59 95 }
60 96 }
61 97
62 - /// Add months to a DateTime, handling edge cases like month-end dates.
98 + /// Advance `dt` by `days` whole **civil** days in `tz`, holding the local
99 + /// wall-clock time-of-day fixed, then convert back to UTC. This is what keeps a
100 + /// "every day at 09:00 local" task at 09:00 across a DST boundary (a fixed
101 + /// `Duration::days` would shift it by the offset change). Falls back to absolute
102 + /// addition if the reconstructed local time lands in a DST gap/fold.
103 + fn add_civil_days(dt: DateTime<Utc>, days: i64, tz: Tz) -> DateTime<Utc> {
104 + let local = dt.with_timezone(&tz);
105 + let new_date = local.date_naive() + Duration::days(days);
106 + tz.from_local_datetime(&new_date.and_time(local.time()))
107 + .single()
108 + .map(|t| t.with_timezone(&Utc))
109 + .unwrap_or(dt + Duration::days(days))
110 + }
111 +
112 + /// Add months to a DateTime in `tz`, handling edge cases like month-end dates.
63 113 ///
64 114 /// Uses absolute month counting (year*12 + month) to add/subtract months, then
65 115 /// clamps the day to the target month's length. Examples:
@@ -67,16 +117,17 @@ pub fn calculate_next_due_with_day(
67 117 /// Mar 31 + 1 month → Apr 30
68 118 ///
69 119 /// When `target_day` is provided, uses that as the intended day-of-month
70 - /// instead of `dt.day()`, preventing drift across short months:
120 + /// instead of the zone-local day, preventing drift across short months:
71 121 /// Jan 31 (target=31) + 1 → Feb 28, then Feb 28 (target=31) + 1 → Mar 31
72 122 ///
73 - /// Preserves the original hour/minute/second. Falls back to the input datetime
74 - /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`).
75 - fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTime<Utc> {
76 -
77 - let year = dt.year();
78 - let month = dt.month() as i32;
79 - let day = target_day.unwrap_or(dt.day());
123 + /// Reads the date fields and rebuilds the result in `tz`, preserving the local
124 + /// hour/minute/second. Falls back to the input datetime if the target local time
125 + /// is ambiguous (e.g., a DST gap via `with_ymd_and_hms`).
126 + fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>, tz: Tz) -> DateTime<Utc> {
127 + let local = dt.with_timezone(&tz);
128 + let year = local.year();
129 + let month = local.month() as i32;
130 + let day = target_day.unwrap_or(local.day());
80 131
81 132 let total_months = year * 12 + month - 1 + months;
82 133 let new_year = total_months.div_euclid(12);
@@ -86,9 +137,10 @@ fn add_months(dt: DateTime<Utc>, months: i32, target_day: Option<u32>) -> DateTi
86 137 let days_in_new_month = days_in_month(new_year, new_month);
87 138 let new_day = day.min(days_in_new_month);
88 139
89 - Utc.with_ymd_and_hms(new_year, new_month, new_day,
90 - dt.hour(), dt.minute(), dt.second())
140 + tz.with_ymd_and_hms(new_year, new_month, new_day,
141 + local.hour(), local.minute(), local.second())
91 142 .single()
143 + .map(|t| t.with_timezone(&Utc))
92 144 .unwrap_or(dt)
93 145 }
94 146
@@ -121,30 +173,41 @@ const RECURRENCE_NS: Uuid = Uuid::from_bytes([
121 173 0xb1, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,
122 174 ]);
123 175
124 - /// Calculate the next due date using a rich recurrence rule.
125 - ///
126 - /// Handles intervals, weekday selection, and monthly specifications.
127 - /// Falls back to simple recurrence for rules with interval=1 and no extras.
176 + /// Calculate the next due date using a rich recurrence rule (UTC wrapper).
128 177 pub fn calculate_next_due_rich(
129 178 current_due: Option<&DateTime<Utc>>,
130 179 rule: &RecurrenceRule,
131 180 ) -> Option<DateTime<Utc>> {
181 + calculate_next_due_rich_in_tz(current_due, rule, Tz::UTC)
182 + }
183 +
184 + /// Calculate the next due date using a rich recurrence rule, in `tz`.
185 + ///
186 + /// Handles intervals, weekday selection, and monthly specifications. Weekday and
187 + /// date fields are read in `tz`, and daily/weekly advance by whole civil days so
188 + /// the local time-of-day is stable across DST.
189 + pub fn calculate_next_due_rich_in_tz(
190 + current_due: Option<&DateTime<Utc>>,
191 + rule: &RecurrenceRule,
192 + tz: Tz,
193 + ) -> Option<DateTime<Utc>> {
132 194 if matches!(rule.pattern, Recurrence::None) {
133 195 return None;
134 196 }
135 197 let base = current_due.copied().unwrap_or_else(Utc::now);
198 + let local = base.with_timezone(&tz);
136 199 let interval = rule.interval.max(1) as i64;
137 200
138 201 match rule.pattern {
139 202 Recurrence::Daily => {
140 - Some(base + Duration::days(interval))
203 + Some(add_civil_days(base, interval, tz))
141 204 }
142 205 Recurrence::Weekly => {
143 206 if rule.weekdays.is_empty() {
144 - return Some(base + Duration::weeks(interval));
207 + return Some(add_civil_days(base, interval * 7, tz));
145 208 }
146 - // Find next matching weekday
147 - let current_wd = base.weekday().num_days_from_monday() as u8;
209 + // Find next matching weekday (weekday read in `tz`).
210 + let current_wd = local.weekday().num_days_from_monday() as u8;
148 211 let mut sorted_days = rule.weekdays.clone();
149 212 sorted_days.sort_unstable();
150 213 sorted_days.dedup();
@@ -152,38 +215,40 @@ pub fn calculate_next_due_rich(
152 215 // Look for next day in the current week (after current weekday)
153 216 if let Some(&next_wd) = sorted_days.iter().find(|&&d| d > current_wd) {
154 217 let diff = (next_wd - current_wd) as i64;
155 - return Some(base + Duration::days(diff));
218 + return Some(add_civil_days(base, diff, tz));
156 219 }
157 220 // Wrap to first day of next interval-week
158 221 let first_wd = sorted_days[0];
159 222 let days_to_end = 7 - current_wd as i64;
160 223 let skip_weeks = (interval - 1) * 7;
161 224 let days = days_to_end + skip_weeks + first_wd as i64;
162 - Some(base + Duration::days(days))
225 + Some(add_civil_days(base, days, tz))
163 226 }
164 227 Recurrence::Monthly => {
165 228 match &rule.monthly_spec {
166 229 Some(MonthlySpec::DayOfMonth { day }) => {
167 - let next = add_months(base, interval as i32, Some(*day));
230 + let next = add_months(base, interval as i32, Some(*day), tz);
168 231 Some(next)
169 232 }
170 233 Some(MonthlySpec::NthWeekday { week, weekday }) => {
171 - let next_base = add_months(base, interval as i32, None);
234 + let next_base = add_months(base, interval as i32, None, tz);
235 + let next_local = next_base.with_timezone(&tz);
172 236 let target = nth_weekday_in_month(
173 - next_base.year(), next_base.month(),
237 + next_local.year(), next_local.month(),
174 238 *week, *weekday,
175 - next_base.hour(), next_base.minute(), next_base.second(),
239 + next_local.hour(), next_local.minute(), next_local.second(),
240 + tz,
176 241 );
177 242 Some(target.unwrap_or(next_base))
178 243 }
179 244 None => {
180 - // Same as legacy monthly
245 + // Same as legacy monthly (end-of-month intent read in `tz`)
181 246 let target_day = {
182 - let day = base.day();
183 - let month_len = days_in_month(base.year(), base.month());
247 + let day = local.day();
248 + let month_len = days_in_month(local.year(), local.month());
184 249 if day == month_len && day >= 29 && day < 31 { Some(31) } else { None }
185 250 };
186 - Some(add_months(base, interval as i32, target_day))
251 + Some(add_months(base, interval as i32, target_day, tz))
187 252 }
188 253 }
189 254 }
@@ -191,16 +256,16 @@ pub fn calculate_next_due_rich(
191 256 }
192 257 }
193 258
194 - /// Find the Nth weekday in a given month.
259 + /// Find the Nth weekday in a given month, constructing the result in `tz`.
195 260 /// `week`: 1-4 for ordinal, -1 for last.
196 261 /// `weekday`: 0=Mon..6=Sun.
262 + /// `year`/`month`/`hour`/`minute`/`second` are civil fields in `tz`.
197 263 fn nth_weekday_in_month(
198 264 year: i32, month: u32,
199 265 week: i8, weekday: u8,
200 266 hour: u32, minute: u32, second: u32,
267 + tz: Tz,
201 268 ) -> Option<DateTime<Utc>> {
202 - use chrono::NaiveDate;
203 -
204 269 let weekday_chrono = match weekday {
205 270 0 => chrono::Weekday::Mon,
206 271 1 => chrono::Weekday::Tue,
@@ -220,7 +285,9 @@ fn nth_weekday_in_month(
220 285 while d.weekday() != weekday_chrono {
221 286 d = d.pred_opt()?;
222 287 }
223 - Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
288 + tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
289 + .single()
290 + .map(|t| t.with_timezone(&Utc))
224 291 } else if (1..=5).contains(&week) {
225 292 // Nth occurrence: start from day 1, find first matching weekday, skip N-1
226 293 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
@@ -233,7 +300,9 @@ fn nth_weekday_in_month(
233 300 if d.month() != month {
234 301 return None; // e.g., 5th Monday doesn't exist
235 302 }
236 - Utc.with_ymd_and_hms(year, month, d.day(), hour, minute, second).single()
303 + tz.with_ymd_and_hms(year, month, d.day(), hour, minute, second)
304 + .single()
305 + .map(|t| t.with_timezone(&Utc))
237 306 } else {
238 307 None
239 308 }
@@ -249,6 +318,17 @@ pub fn expand_recurrence(
249 318 range_start: DateTime<Utc>,
250 319 range_end: DateTime<Utc>,
251 320 ) -> Vec<Event> {
321 + expand_recurrence_in_tz(event, range_start, range_end, Tz::UTC)
322 + }
323 +
324 + /// Expand a recurring event into virtual instances within a date range, advancing
325 + /// occurrences in `tz` so the local time-of-day is stable across DST.
326 + pub fn expand_recurrence_in_tz(
327 + event: &Event,
328 + range_start: DateTime<Utc>,
329 + range_end: DateTime<Utc>,
330 + tz: Tz,
331 + ) -> Vec<Event> {
252 332 let rule = match event.effective_recurrence_rule() {
253 333 Some(r) => r,
254 334 None => return vec![],
@@ -285,7 +365,7 @@ pub fn expand_recurrence(
285 365 }
286 366
287 367 // Advance to next occurrence
288 - match calculate_next_due_rich(Some(&cursor), &rule) {
368 + match calculate_next_due_rich_in_tz(Some(&cursor), &rule, tz) {
289 369 Some(next) if next > cursor => cursor = next,
290 370 _ => break, // prevent infinite loop
291 371 }
@@ -710,4 +790,69 @@ mod tests {
710 790 assert_eq!(a.id, b.id);
711 791 }
712 792 }
793 +
794 + // ============ DST / time-zone-aware recurrence (Run #28) ============
795 +
796 + #[test]
797 + fn test_daily_recurrence_holds_local_time_across_spring_forward() {
798 + use chrono_tz::America::New_York;
799 + // 2026-03-08 is US spring-forward (02:00 -> 03:00). A task at 09:00 local on
800 + // Mar 7 must land at 09:00 local on Mar 8 — not 10:00 as fixed-24h-UTC would give.
801 + let start = New_York.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).single().unwrap()
802 + .with_timezone(&Utc);
803 + let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
804 + let next_local = next.with_timezone(&New_York);
805 + assert_eq!(next_local.day(), 8);
806 + assert_eq!(next_local.hour(), 9, "local hour must stay 09:00 across DST");
807 + // The UTC instant shifts by 23h (a short civil day), proving DST was honored.
808 + assert_eq!((next - start).num_hours(), 23);
809 + }
810 +
811 + #[test]
812 + fn test_daily_recurrence_holds_local_time_across_fall_back() {
813 + use chrono_tz::America::New_York;
814 + // 2026-11-01 is US fall-back (02:00 -> 01:00). 09:00 local Oct 31 -> 09:00 local Nov 1.
815 + let start = New_York.with_ymd_and_hms(2026, 10, 31, 9, 0, 0).single().unwrap()
816 + .with_timezone(&Utc);
817 + let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Daily, New_York).unwrap();
818 + let next_local = next.with_timezone(&New_York);
819 + assert_eq!(next_local.day(), 1);
820 + assert_eq!(next_local.hour(), 9);
821 + assert_eq!((next - start).num_hours(), 25, "a long civil day spans the fall-back");
822 + }
823 +
824 + #[test]
825 + fn test_weekly_recurrence_holds_local_time_across_dst() {
826 + use chrono_tz::America::New_York;
827 + // Mar 5 (Thu) 08:00 local -> Mar 12, still 08:00 local, despite the Mar 8 transition.
828 + let start = New_York.with_ymd_and_hms(2026, 3, 5, 8, 0, 0).single().unwrap()
829 + .with_timezone(&Utc);
830 + let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Weekly, New_York).unwrap();
831 + let next_local = next.with_timezone(&New_York);
832 + assert_eq!(next_local.day(), 12);
833 + assert_eq!(next_local.hour(), 8);
834 + }
835 +
836 + #[test]
837 + fn test_monthly_recurrence_holds_local_time_across_dst() {
838 + use chrono_tz::America::New_York;
839 + // Feb 20 09:00 local -> Mar 20 09:00 local, crossing the Mar 8 spring-forward.
840 + let start = New_York.with_ymd_and_hms(2026, 2, 20, 9, 0, 0).single().unwrap()
841 + .with_timezone(&Utc);
842 + let next = calculate_next_due_in_tz(Some(&start), &Recurrence::Monthly, New_York).unwrap();
843 + let next_local = next.with_timezone(&New_York);
844 + assert_eq!(next_local.month(), 3);
845 + assert_eq!(next_local.day(), 20);
846 + assert_eq!(next_local.hour(), 9);
847 + }
848 +
849 + #[test]
850 + fn test_utc_wrapper_unaffected_by_dst_logic() {
851 + // The legacy UTC entry points must still add a fixed 24h (no zone involved),
852 + // so existing callers and instants are unchanged.
853 + let start = Utc.with_ymd_and_hms(2026, 3, 7, 9, 0, 0).unwrap();
854 + let next = calculate_next_due(Some(&start), &Recurrence::Daily).unwrap();
855 + assert_eq!((next - start).num_hours(), 24);
856 + assert_eq!(next.hour(), 9);
857 + }
713 858 }
@@ -62,6 +62,7 @@ pub const EXCLUDED_TABLES: &[&str] = &[
62 62 "imap_folder_sync_state", // sync-transient IMAP state, re-derived on next sync
63 63 "sync_changelog", // sync internals, regenerated
64 64 "sync_state", // sync cursors/flags, internal
65 + "hlc_state", // hybrid logical clock, device-local sync internal
65 66 ];
66 67
67 68 /// Restore a backup into the database as a single transaction.
@@ -724,10 +725,13 @@ async fn restore_weekly_reviews(
724 725 result: &mut RestoreResult,
725 726 ) -> Result<()> {
726 727 for review in &input.weekly_reviews {
728 + // Restore vacation_days verbatim. Earlier code silently dropped any value > 6,
729 + // which violates this module's "every row inserted exactly as backed up" contract
730 + // (ultra-fuzz Run #28 MINOR): a backup is a faithful snapshot, not a place to
731 + // re-validate. The 0-6 weekday domain is enforced on the write path, not here.
727 732 let vacation_days = review
728 733 .vacation_days
729 734 .iter()
730 - .filter(|&&d| d <= 6)
731 735 .map(|d| d.to_string())
732 736 .collect::<Vec<_>>()
733 737 .join(",");
@@ -0,0 +1,23 @@
1 + -- Hybrid logical clock for cross-device sync conflict resolution (ultra-fuzz Run #28).
2 + --
3 + -- Conflict resolution previously ordered by wall-clock `client_timestamp`, which let a
4 + -- device with a skewed-fast clock win every conflict and made DELETE unconditionally
5 + -- beat a strictly-newer edit (permanent data loss). SyncKit now orders by an HLC that
6 + -- travels inside the E2E-encrypted payload. This migration adds the local clock state
7 + -- and per-changelog-row HLC stamps.
8 + --
9 + -- The HLC's node component is always this device's id, so only (wall_ms, counter) are
10 + -- persisted here. Rows are stamped lazily when first read for push or conflict
11 + -- detection (see src-tauri/src/sync_service/hlc.rs), so the changelog triggers stay
12 + -- unchanged.
13 +
14 + ALTER TABLE sync_changelog ADD COLUMN hlc_wall INTEGER;
15 + ALTER TABLE sync_changelog ADD COLUMN hlc_counter INTEGER;
16 +
17 + CREATE TABLE IF NOT EXISTS hlc_state (
18 + id INTEGER PRIMARY KEY CHECK (id = 1),
19 + wall_ms INTEGER NOT NULL DEFAULT 0,
20 + counter INTEGER NOT NULL DEFAULT 0
21 + );
22 +
23 + INSERT OR IGNORE INTO hlc_state (id, wall_ms, counter) VALUES (1, 0, 0);
@@ -38,6 +38,7 @@ serde_json = { workspace = true }
38 38 # Utilities
39 39 chrono = { workspace = true }
40 40 chrono-tz = { workspace = true }
41 + iana-time-zone = "0.1"
41 42 uuid = { workspace = true }
42 43
43 44 # Email
@@ -9,7 +9,7 @@ use std::sync::Arc;
9 9 use tauri::State;
10 10 use tracing::instrument;
11 11
12 - use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence};
12 + use goingson_core::{Conflict, DbValue, NewEvent, Recurrence, TaskId, TimelineItem, UpdateEvent, detect_conflicts, expand_recurrence_in_tz};
13 13 use chrono::Datelike;
14 14
15 15 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -116,7 +116,7 @@ pub async fn get_day_planning(
116 116 let existing_ids: std::collections::HashSet<_> = events.iter().map(|e| e.id).collect();
117 117 for r in recurring {
118 118 if !existing_ids.contains(&r.id) {
119 - let expanded = expand_recurrence(&r, day_start, day_end);
119 + let expanded = expand_recurrence_in_tz(&r, day_start, day_end, crate::tz::system_tz());
120 120 events.extend(expanded);
121 121 // Check if the original also falls on this day
122 122 let effective_end = r.end_time.unwrap_or(r.start_time + Duration::hours(1));
@@ -9,7 +9,7 @@ use std::sync::Arc;
9 9 use tauri::State;
10 10 use tracing::instrument;
11 11
12 - use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence};
12 + use goingson_core::{BlockType, ContactId, DbValue, Event, EventId, NewEvent, ParseableEnum, ProjectId, Recurrence, RecurrenceRule, TaskId, UpdateEvent, Validate, expand_recurrence_in_tz};
13 13
14 14 use crate::state::{AppState, DESKTOP_USER_ID};
15 15 use super::{ApiError, OptionNotFound};
@@ -218,7 +218,7 @@ fn expand_and_merge(events: Vec<Event>, range_start: DateTime<Utc>, range_end: D
218 218 for event in events {
219 219 if event.has_recurrence() && !event.is_recurring_instance {
220 220 // Add virtual instances within the range
221 - let expanded = expand_recurrence(&event, range_start, range_end);
221 + let expanded = expand_recurrence_in_tz(&event, range_start, range_end, crate::tz::system_tz());
222 222 result.extend(expanded);
223 223 // Include the original if it falls within range
224 224 let effective_end = event.end_time.unwrap_or(event.start_time + Duration::hours(1));
@@ -19,7 +19,8 @@ use tracing::instrument;
19 19
20 20 use goingson_core::{
21 21 Annotation, MilestoneStatus, NewTask, ParseableEnum, Priority, Recurrence, RecurrenceRule,
22 - Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due, calculate_next_due_rich,
22 + Subtask, Task, TaskStatus, UpdateTask, Validate, calculate_next_due_in_tz,
23 + calculate_next_due_rich_in_tz,
23 24 calculate_urgency, parse_quick_add, TaskId, ProjectId, MilestoneId, ContactId, EmailId,
24 25 date_utils::{format_relative_date, format_relative_future},
25 26 };
@@ -582,10 +583,11 @@ pub async fn complete_task(state: State<'_, Arc<AppState>>, id: TaskId) -> Resul
582 583
583 584 // Build next recurring instance if needed
584 585 let next_new_task = if task.has_recurrence() {
586 + let tz = crate::tz::system_tz();
585 587 let next_due = if let Some(ref rule) = task.recurrence_rule {
586 - calculate_next_due_rich(task.due.as_ref(), rule)
588 + calculate_next_due_rich_in_tz(task.due.as_ref(), rule, tz)
587 589 } else {
588 - calculate_next_due(task.due.as_ref(), &task.recurrence)
590 + calculate_next_due_in_tz(task.due.as_ref(), &task.recurrence, tz)
589 591 };
590 592 let created_at = Utc::now();
591 593 let fresh_urgency = calculate_urgency(
@@ -12,7 +12,7 @@ use tracing::instrument;
12 12 use goingson_core::weekly_review::{
13 13 self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewInput,
14 14 };
15 - use goingson_core::{TaskId, WeeklyReview, expand_recurrence};
15 + use goingson_core::{TaskId, WeeklyReview, expand_recurrence_in_tz};
16 16
17 17 use crate::state::{AppState, DESKTOP_USER_ID};
18 18 use super::ApiError;
@@ -160,15 +160,16 @@ pub async fn get_weekly_review(
160 160 // Expand recurring events into both occurred and upcoming ranges
161 161 let mut events_occurred = events_occurred?;
162 162 let past_range_end = now.min(week_end_dt);
163 + let tz = crate::tz::system_tz();
163 164 for r in &recurring_events {
164 - let expanded = expand_recurrence(r, week_start_dt, past_range_end);
165 + let expanded = expand_recurrence_in_tz(r, week_start_dt, past_range_end, tz);
165 166 events_occurred.extend(expanded);
166 167 }
167 168 events_occurred.sort_by_key(|e| e.start_time);
168 169
169 170 let mut upcoming_events = upcoming_events?;
170 171 for r in &recurring_events {
171 - let expanded = expand_recurrence(r, now, next_week_end_dt);
172 + let expanded = expand_recurrence_in_tz(r, now, next_week_end_dt, tz);
172 173 upcoming_events.extend(expanded);
173 174 }
174 175 upcoming_events.sort_by_key(|e| e.start_time);
@@ -16,6 +16,7 @@ pub mod oauth;
16 16 pub mod state;
17 17 pub mod sync_scheduler;
18 18 pub mod sync_service;
19 + pub mod tz;
19 20
20 21 // Desktop-only: file watching for external DB changes
21 22 #[cfg(not(any(target_os = "ios", target_os = "android")))]
@@ -3,6 +3,7 @@
3 3 use std::path::Path;
4 4
5 5 use goingson_core::CoreError;
6 + use sha2::{Digest, Sha256};
6 7 use sqlx::SqlitePool;
7 8 use synckit_client::SyncKitClient;
8 9 use tracing::{debug, info, warn};
@@ -134,6 +135,22 @@ pub async fn download_missing_blobs(
134 135 }
135 136 };
136 137
138 + // Verify content-addressed integrity before committing the file. The store's
139 + // invariant is "the file at blobs/<hash> hashes to <hash>" — enforced on write
140 + // (add_attachment) but previously only assumed on read. E2E AEAD stops a network
141 + // attacker, but a server-side corruption or mis-bound ciphertext would otherwise
142 + // land wrong bytes under a trusted name and be handed to open::that()
143 + // (ultra-fuzz Run #28 S1). Hashing here, before the rename, keeps the store clean.
144 + let actual = format!("{:x}", Sha256::digest(&data));
145 + if actual != *hash {
146 + warn!(
147 + "Blob {} failed integrity check (content hashed to {}); discarding download",
148 + short(hash),
149 + short(&actual)
150 + );
151 + continue;
152 + }
153 +
137 154 // Write to disk atomically (tmp + rename) to prevent corrupt partial files
138 155 let tmp_path = path.with_extension("tmp");
139 156 if let Err(e) = tokio::fs::write(&tmp_path, &data).await {
@@ -0,0 +1,90 @@
1 + //! Hybrid logical clock persistence for cross-device sync conflict resolution.
2 + //!
3 + //! SyncKit orders conflicts by an [`Hlc`] rather than a bare wall-clock timestamp
4 + //! (ultra-fuzz Run #28): a skewed-fast device no longer wins every conflict, and a
5 + //! strictly-newer edit beats an older delete. This module owns the device's
6 + //! persistent clock — `(wall_ms, counter)` in the `hlc_state` table; the HLC's node
7 + //! component is always this device's id, so it is not stored.
8 + //!
9 + //! Local changelog rows are stamped **lazily**: when a row is first read for push or
10 + //! conflict detection it gets the next HLC, and the stamp is written back onto the
11 + //! row. This keeps the changelog triggers unchanged and guarantees push and
12 + //! conflict-detection read the same HLC for a given change.
13 +
14 + use chrono::Utc;
15 + use goingson_core::CoreError;
16 + use sqlx::SqlitePool;
17 + use synckit_client::Hlc;
18 + use uuid::Uuid;
19 +
20 + /// Load the persistent clock, reattaching `node` (which is not stored).
21 + async fn load_clock(pool: &SqlitePool, node: Uuid) -> Result<Hlc, CoreError> {
22 + let (wall_ms, counter): (i64, i64) =
23 + sqlx::query_as("SELECT wall_ms, counter FROM hlc_state WHERE id = 1")
24 + .fetch_one(pool)
25 + .await
26 + .map_err(CoreError::database)?;
27 + Ok(Hlc { wall_ms, counter: counter as u32, node })
28 + }
29 +
30 + /// Stamp every unpushed changelog row that lacks an HLC, in id order, advancing the
31 + /// persistent clock once per row. Idempotent — already-stamped rows are skipped — so
32 + /// push and conflict-detection observe identical stamps. Runs in one transaction so a
33 + /// crash mid-stamp leaves the clock and the rows consistent (all-or-nothing).
34 + pub async fn assign_pending_hlcs(pool: &SqlitePool, device_id: Uuid) -> Result<(), CoreError> {
35 + let ids: Vec<(i64,)> = sqlx::query_as(
36 + "SELECT id FROM sync_changelog WHERE pushed = 0 AND hlc_wall IS NULL ORDER BY id ASC",
37 + )
38 + .fetch_all(pool)
39 + .await
40 + .map_err(CoreError::database)?;
41 +
42 + if ids.is_empty() {
43 + return Ok(());
44 + }
45 +
46 + let mut tx = pool.begin().await.map_err(CoreError::database)?;
47 +
48 + let (wall_ms, counter): (i64, i64) =
49 + sqlx::query_as("SELECT wall_ms, counter FROM hlc_state WHERE id = 1")
50 + .fetch_one(&mut *tx)
51 + .await
52 + .map_err(CoreError::database)?;
53 + let mut clock = Hlc { wall_ms, counter: counter as u32, node: device_id };
54 +
55 + for (id,) in ids {
56 + clock = Hlc::tick(clock, Utc::now().timestamp_millis(), device_id);
57 + sqlx::query("UPDATE sync_changelog SET hlc_wall = ?, hlc_counter = ? WHERE id = ?")
58 + .bind(clock.wall_ms)
59 + .bind(clock.counter as i64)
60 + .bind(id)
61 + .execute(&mut *tx)
62 + .await
63 + .map_err(CoreError::database)?;
64 + }
65 +
66 + sqlx::query("UPDATE hlc_state SET wall_ms = ?, counter = ? WHERE id = 1")
67 + .bind(clock.wall_ms)
68 + .bind(clock.counter as i64)
69 + .execute(&mut *tx)
70 + .await
71 + .map_err(CoreError::database)?;
72 +
73 + tx.commit().await.map_err(CoreError::database)?;
74 + Ok(())
75 + }
76 +
77 + /// Advance the persistent clock past a remote HLC observed during pull, so that
78 + /// subsequent local changes causally follow it. Monotonic: advancing past the batch
79 + /// maximum is enough to cover every change in the batch.
80 + pub async fn observe_remote(pool: &SqlitePool, remote: Hlc, device_id: Uuid) -> Result<(), CoreError> {
81 + let clock = load_clock(pool, device_id).await?;
82 + let advanced = Hlc::observe(clock, remote, Utc::now().timestamp_millis(), device_id);
83 + sqlx::query("UPDATE hlc_state SET wall_ms = ?, counter = ? WHERE id = 1")
84 + .bind(advanced.wall_ms)
85 + .bind(advanced.counter as i64)
86 + .execute(pool)
87 + .await
88 + .map_err(CoreError::database)?;
89 + Ok(())
90 + }
@@ -21,6 +21,7 @@
21 21
22 22 mod apply;
23 23 pub(crate) mod blob_sync;
24 + mod hlc;
24 25 mod pull;
25 26 mod push;
26 27 mod state;
@@ -4,13 +4,14 @@ use chrono::Utc;
4 4 use goingson_core::CoreError;
5 5 use sqlx::{SqliteConnection, SqlitePool};
6 6 use synckit_client::{
7 - ChangeEntry, ChangeOp, Resolution, SyncKitClient,
7 + ChangeEntry, ChangeOp, Hlc, Resolution, SyncKitClient,
8 8 detect_conflicts, resolve_lww,
9 9 };
10 10 use tracing::{debug, warn};
11 11 use uuid::Uuid;
12 12
13 13 use super::state::{get_sync_state, set_sync_state};
14 + use super::hlc::{assign_pending_hlcs, observe_remote};
14 15 use super::{UPSERT_ORDER, DELETE_ORDER};
15 16 use super::apply::{apply_upsert, apply_delete};
16 17
@@ -24,8 +25,9 @@ pub async fn pull_changes(
24 25 let mut total_applied: i64 = 0;
25 26 let mut changed_tables: std::collections::HashSet<String> = std::collections::HashSet::new();
26 27
27 - // Read local pending changes once (unpushed changelog entries)
28 - let local_pending = read_local_pending(pool).await?;
28 + // Read local pending changes once (unpushed changelog entries). This also
29 + // stamps them with their HLC so conflict resolution can compare clocks.
30 + let local_pending = read_local_pending(pool, device_id).await?;
29 31
30 32 loop {
31 33 let (changes, new_cursor, has_more) = client
@@ -38,6 +40,12 @@ pub async fn pull_changes(
38 40 break;
39 41 }
40 42
43 + // Advance our clock past everything in this batch, so future local changes
44 + // causally follow the remote ones. Observing the batch maximum suffices.
45 + if let Some(max_remote) = changes.iter().map(|p| p.entry.hlc).max() {
46 + observe_remote(pool, max_remote, device_id).await?;
47 + }
48 +
41 49 // Detect conflicts between remote changes and local pending
42 50 let (clean, conflicts) = detect_conflicts(changes, &local_pending, device_id);
43 51
@@ -119,9 +127,15 @@ pub struct PullOutcome {
119 127 }
120 128
121 129 /// Read unpushed changelog entries as ChangeEntry values for conflict detection.
122 - async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreError> {
123 - let rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
124 - "SELECT table_name, op, row_id, timestamp, data FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC"
130 + ///
131 + /// Stamps any not-yet-stamped rows with their HLC first, so the clocks used here
132 + /// match the ones the push path will send.
133 + async fn read_local_pending(pool: &SqlitePool, device_id: Uuid) -> Result<Vec<ChangeEntry>, CoreError> {
134 + assign_pending_hlcs(pool, device_id).await?;
135 +
136 + let rows: Vec<(String, String, String, String, Option<String>, Option<i64>, Option<i64>)> = sqlx::query_as(
137 + "SELECT table_name, op, row_id, timestamp, data, hlc_wall, hlc_counter \
138 + FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC"
125 139 )
126 140 .fetch_all(pool)
127 141 .await
@@ -129,7 +143,7 @@ async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreE
129 143
130 144 let entries: Vec<ChangeEntry> = rows
131 145 .into_iter()
132 - .filter_map(|(table, op, row_id, timestamp, data)| {
146 + .filter_map(|(table, op, row_id, timestamp, data, hlc_wall, hlc_counter)| {
133 147 let ts = chrono::DateTime::parse_from_rfc3339(&timestamp)
134 148 .map(|dt| dt.with_timezone(&Utc))
135 149 .unwrap_or(chrono::DateTime::UNIX_EPOCH);
@@ -137,11 +151,17 @@ async fn read_local_pending(pool: &SqlitePool) -> Result<Vec<ChangeEntry>, CoreE
137 151 let change_op = ChangeOp::from_str_opt(&op)?;
138 152 let json_data = data.and_then(|d| serde_json::from_str(&d).ok());
139 153
154 + let hlc = match (hlc_wall, hlc_counter) {
155 + (Some(wall), Some(counter)) => Hlc { wall_ms: wall, counter: counter as u32, node: device_id },
156 + _ => Hlc::from_legacy(ts.timestamp_millis(), device_id),
157 + };
158 +
140 159 Some(ChangeEntry {
141 160 table,
142 161 op: change_op,
143 162 row_id,
144 163 timestamp: ts,
164 + hlc,
145 165 data: json_data,
146 166 })
147 167 })
@@ -3,22 +3,27 @@
3 3 use chrono::Utc;
4 4 use goingson_core::CoreError;
5 5 use sqlx::SqlitePool;
6 - use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient};
6 + use synckit_client::{ChangeEntry, ChangeOp, Hlc, SyncKitClient};
7 7 use tracing::{debug, warn};
8 8 use uuid::Uuid;
9 9
10 10 use super::PUSH_BATCH_LIMIT;
11 + use super::hlc::assign_pending_hlcs;
11 12
12 13 pub async fn push_changes(
13 14 pool: &SqlitePool,
14 15 client: &SyncKitClient,
15 16 device_id: Uuid,
16 17 ) -> Result<i64, CoreError> {
18 + // Stamp any unpushed changes with their HLC before reading them for the wire.
19 + assign_pending_hlcs(pool, device_id).await?;
20 +
17 21 let mut total_pushed: i64 = 0;
18 22
19 23 loop {
20 - let rows: Vec<(i64, String, String, String, String, Option<String>)> = sqlx::query_as(
21 - "SELECT id, table_name, op, row_id, timestamp, data FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC LIMIT ?"
24 + let rows: Vec<(i64, String, String, String, String, Option<String>, Option<i64>, Option<i64>)> = sqlx::query_as(
25 + "SELECT id, table_name, op, row_id, timestamp, data, hlc_wall, hlc_counter \
26 + FROM sync_changelog WHERE pushed = 0 ORDER BY id ASC LIMIT ?"
22 27 )
23 28 .bind(PUSH_BATCH_LIMIT)
24 29 .fetch_all(pool)
@@ -33,7 +38,7 @@ pub async fn push_changes(
33 38 let mut pushed_ids: Vec<i64> = Vec::new();
34 39 let changes: Vec<ChangeEntry> = rows
35 40 .into_iter()
36 - .filter_map(|(id, table, op, row_id, timestamp, data)| {
41 + .filter_map(|(id, table, op, row_id, timestamp, data, hlc_wall, hlc_counter)| {
37 42 // Always mark as pushed (even unknown ops) to avoid infinite re-fetch
38 43 pushed_ids.push(id);
39 44
@@ -51,11 +56,19 @@ pub async fn push_changes(
51 56 }
52 57 };
53 58
59 + // Stamped by assign_pending_hlcs above; fall back to a
60 + // timestamp-derived HLC if a row was somehow left unstamped.
61 + let hlc = match (hlc_wall, hlc_counter) {
62 + (Some(wall), Some(counter)) => Hlc { wall_ms: wall, counter: counter as u32, node: device_id },
63 + _ => Hlc::from_legacy(ts.timestamp_millis(), device_id),
64 + };
65 +
54 66 Some(ChangeEntry {
55 67 table,
56 68 op: change_op,
57 69 row_id,
58 70 timestamp: ts,
71 + hlc,
59 72 data: json_data,
60 73 })
61 74 })
@@ -45,6 +45,7 @@ use sqlx::SqlitePool;
45 45 op,
46 46 row_id: row_id.to_string(),
47 47 timestamp: chrono::Utc::now(),
48 + hlc: Default::default(),
48 49 data,
49 50 }
50 51 }
@@ -0,0 +1,17 @@
1 + //! System time-zone resolution for recurrence.
2 + //!
3 + //! GoingsOn stores instants in UTC and has no per-user time-zone preference, so
4 + //! "the user's zone" is the operating system's zone. Recurrence advances dates in
5 + //! this zone (see `goingson_core::recurrence`) so a fixed local time-of-day — "every
6 + //! day at 09:00" — survives daylight-saving transitions instead of drifting an hour.
7 +
8 + use chrono_tz::Tz;
9 +
10 + /// The user's IANA time zone, resolved from the OS. Falls back to UTC if the OS
11 + /// zone can't be read or doesn't parse as a known IANA name.
12 + pub fn system_tz() -> Tz {
13 + iana_time_zone::get_timezone()
14 + .ok()
15 + .and_then(|name| name.parse().ok())
16 + .unwrap_or(Tz::UTC)
17 + }