oficina / calendar Public

Bound recurrence stepping and report incomplete expansion honestly

Paul Campbell pushed by paul@keymail.dev 813d8a63f92324c8680a066d82a4bd1e5e852217 parent 9400eb0
7 files changed, +437 −67
  • docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md +81 −0
  • internal/calendar/occurrences.go +20 −9
  • internal/calendar/occurrences_bounds.go +57 −0
  • internal/calendar/occurrences_step.go +70 −58
  • internal/calendar/recurrence_bounds_probe_test.go +84 −0
  • internal/calendar/recurrence_horizon_test.go +95 −0
  • internal/calendartest/recurrence_invalid_test.go +30 −0
diff --git a/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md b/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md
new file mode 100644
index 0000000..e1a1346
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-recurrence-stepping-bounds.md
@@ -0,0 +1,81 @@
+# Recurrence stepping bounds
+
+Status: revision 2 APPROVED by Claude Fable5.1 (claude-fable-5-1), 2026-09-07.
+Evidence: /tmp/oficina-calendar-recurrence-review/design-review2.json.
+Baseline: application branch 2074881; live binary d961b65. This support
+branch does not approve the application's entire unmerged history.
+
+Scope: bounded, exact stepping within the existing recurrence subset.
+Keep every currently parsed positive INTERVAL; no parser or picker limit.
+No UI copy, schema, migrations, COUNT fast-forwarding or new RRULE shapes.
+
+## Evidence
+
+Weekly INTERVAL=100000000 with BYDAY exceeds a two-second child deadline.
+Civil 1600-01-01 to 2000-01-01 saturates at 106751 rather than146097 days.
+A valid INTERVAL=20871 Saturday series silently loses its2000 occurrence
+and reports complete. Yearly MaxInt wraps backwards. Invalid zone/start/end
+reports complete, and a store-level test confirms strict busy-time reads
+return nil rather than ErrIncompleteExpansion for those invalid fields.
+
+## Revised implementation contract
+
+1. Use civil(t).Unix()/86400 for integer civil days within the representable
+ record domain: year0000 through9999, the four-digit local date format.
+ All parsed source dates fit this domain. Generated candidates are bounded
+ before arithmetic; no arbitrary-year Unix round-trip machinery is needed.
+ daysBetween subtracts civil-day integers, never time.Duration or floats.
+2. Weekly BYDAY considers remaining selected weekdays in the current eligible
+ week, then the first selected weekday in the next INTERVAL-th week, ordered
+ from WKST. cur is eligible by induction from DTSTART. At most13 weekday
+ candidates; no scanning proportional to INTERVAL and no saturated phase.
+ Construct each candidate with the original proto clock as before.
+3. Check distance to the final representable civil date by division before
+ multiplying. Daily compares interval with remaining civil days; weekly
+ compares interval with remaining days/7 before computing its week jump.
+ Monthly compares interval with remaining month indexes/step; yearly with
+ remaining years/step. Thus every addition/product stays inside the small
+ four-digit date domain. Do not change parsing or stored INTERVAL.
+4. Test calendar-date existence before zone resolution. Yearly checks month
+ length before constructing the candidate; monthly ordinal weekday arithmetic
+ uses UTC civil weekdays, then resolves the selected date in loc. A complete
+ flat4800-month-step or400-year-step search covers every Gregorian residue
+ reachable by any integer interval, because those periods repeat the calendar.
+ No match after that full search proves exhaustion. A horizon reached first
+ does not prove exhaustion. No gcd machinery is required.
+5. Stepping distinguishes candidate, proved exhaustion and incomplete. A
+ horizon refusal is ALWAYS INCOMPLETE, regardless of query end: this avoids
+ claiming a UTC cutoff for zones whose next local year starts before UTC's.
+ Invalid Rule construction, non-advancing resolved candidates and resolved
+ dates outside year0000..9999 also report incomplete. Ordinary DST clock
+ normalization remains unchanged; intended-versus-resolved dates across a
+ midnight gap remain a separate pre-existing issue, not silently redesigned.
+6. Invalid zone/start/end or reversed event/window intervals make strict
+ expansion incomplete. For an unreadable end preserve tolerant instantaneous
+ output where possible, but retain an input-validity flag so no stop reason
+ can turn it into complete. Existing canceled/note exceptions remain.
+7. COUNT/UNTIL satisfaction, passing the window, a valid one-off or proved
+ exhaustion are completion proofs. Check COUNT after generating its final
+ candidate so no unneeded next step can turn completed COUNT into a horizon
+ failure. Keep existing100000 outer/1000 output caps and EXDATE ordering.
+
+## Validation
+
+Adapt the two-second huge-interval probe to expect prompt incomplete refusal;
+add INTERVAL=400000 within year9999 with exact date/weekday/clock. Keep the
+four-century observable regression, reversed civil span, huge-interval cases
+for every frequency, exact near-horizon boundaries, monthly31st/fifth weekday
+and leap-day skips, WKST/DTSTART phase and existing DST/span tests.
+
+Strict store regression must fail before the fix and pass after it while the
+view remains tolerant. Test invalid input, COUNT completion near the horizon,
+query beyond the horizon and unsupported direct Rule values. Mutation-prove
+bounded stepping, exact day arithmetic and incomplete propagation. Run make ci,
+then independent Fable branch review and actual CI before owner integration.
+No production rollout of the unmerged application history is authorized here.
+
+Revision1 was REQUEST CHANGES by Claude Fable5.1 (claude-fable-5-1), evidence
+/tmp/oficina-calendar-recurrence-review/design-review.json. This revision
+accepts the four-digit horizon, exact civil-day arithmetic, pre-zone cycle
+proof and bounded weekly induction; explicitly chooses safe incomplete horizon
+handling from the review's allowed alternatives.
diff --git a/internal/calendar/occurrences.go b/internal/calendar/occurrences.go
index eb0db8e..af85413 100644
--- a/internal/calendar/occurrences.go
+++ b/internal/calendar/occurrences.go
@@ -56,15 +56,19 @@ func Expand(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string
// read as "free" to a booking page. Anything that decides free time
// asks this form and fails closed on false (booking spec §4.1, B23).
func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdates string, from, to int64) ([]Occurrence, bool) {
+ if from >= to || !validExpansionRule(r) {
+ return nil, false
+ }
loc, err := Zone(tz)
if err != nil {
- return nil, true
+ return nil, false
}
start, _, err := ParseLocal(startLocal, loc)
if err != nil {
- return nil, true
+ return nil, false
}
end, _, err := ParseLocal(endLocal, loc)
+ validInput := err == nil && inDateHorizon(start) && inDateHorizon(end) && !end.Before(start)
if err != nil {
// An event with an unreadable end still has a start; treat it
// as instantaneous rather than dropping it entirely.
@@ -93,7 +97,10 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
break
}
occStart := cur
- occEnd := shiftDays(cur, dayspan, end)
+ occEnd, endOK := shiftDays(cur, dayspan, end)
+ if !endOK || occEnd.Before(occStart) {
+ break
+ }
su := occStart.Unix()
eu := occEnd.Unix()
@@ -121,20 +128,24 @@ func ExpandComplete(startLocal, endLocal, tz string, allDay bool, r Rule, exdate
break
}
- next, ok := advance(cur, start, r, loc)
- if !ok {
+ if r.Count > 0 && generated >= r.Count {
complete = true
break
}
+ next, state := advance(cur, start, r, loc)
+ if state != stepCandidate {
+ complete = state == stepExhausted
+ break
+ }
cur = next
}
- return out, complete
+ return out, complete && validInput
}
// shiftDays puts proto's time of day onto a date dayspan days after cur.
-func shiftDays(cur time.Time, dayspan int, proto time.Time) time.Time {
- d := cur.AddDate(0, 0, dayspan)
- return time.Date(d.Year(), d.Month(), d.Day(), proto.Hour(), proto.Minute(), 0, 0, cur.Location())
+func shiftDays(cur time.Time, dayspan int, proto time.Time) (time.Time, bool) {
+ next, state := candidateOnDay(civilDay(cur)+int64(dayspan), proto, cur.Location())
+ return next, state == stepCandidate
}
func untilLocal(r Rule, loc *time.Location) time.Time {
diff --git a/internal/calendar/occurrences_bounds.go b/internal/calendar/occurrences_bounds.go
new file mode 100644
index 0000000..f644e52
--- /dev/null
+++ b/internal/calendar/occurrences_bounds.go
@@ -0,0 +1,57 @@
+package calendar
+
+import "time"
+
+var firstCivilDay = civilDay(time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC))
+var lastCivilDay = civilDay(time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC))
+
+func civilDay(t time.Time) int64 { return civil(t).Unix() / 86400 }
+func inDateHorizon(t time.Time) bool { return t.Year() >= 0 && t.Year() <= 9999 }
+
+func candidateOnDay(day int64, proto time.Time, loc *time.Location) (time.Time, stepState) {
+ if day < firstCivilDay || day > lastCivilDay {
+ return time.Time{}, stepIncomplete
+ }
+ candidate := onDate(time.Unix(day*86400, 0).UTC(), proto, loc)
+ if !inDateHorizon(candidate) {
+ return time.Time{}, stepIncomplete
+ }
+ return candidate, stepCandidate
+}
+
+func validExpansionRule(r Rule) bool {
+ if r.Count < 0 || r.Count > 0 && r.HasUntil {
+ return false
+ }
+ if r.HasUntil && !inDateHorizon(r.Until) {
+ return false
+ }
+ if r.Freq == "" {
+ return len(r.ByDay) == 0 && r.Ordinal == 0 && r.MonthDay == 0
+ }
+ if r.Interval < 1 {
+ return false
+ }
+ switch r.Freq {
+ case FreqWeekly:
+ if r.WeekStart < time.Sunday || r.WeekStart > time.Saturday || r.MonthDay != 0 || r.Ordinal != 0 {
+ return false
+ }
+ for _, day := range r.ByDay {
+ if day < time.Sunday || day > time.Saturday {
+ return false
+ }
+ }
+ case FreqMonthly:
+ if len(r.ByDay) != 0 || r.MonthDay < 0 || r.MonthDay > 31 || r.Ordinal != 0 && (r.MonthDay != 0 || r.OrdinalDay < time.Sunday || r.OrdinalDay > time.Saturday) {
+ return false
+ }
+ case FreqDaily, FreqYearly:
+ if len(r.ByDay) != 0 || r.MonthDay != 0 || r.Ordinal != 0 {
+ return false
+ }
+ default:
+ return false
+ }
+ return true
+}
diff --git a/internal/calendar/occurrences_step.go b/internal/calendar/occurrences_step.go
index b010ef2..23694c3 100644
--- a/internal/calendar/occurrences_step.go
+++ b/internal/calendar/occurrences_step.go
@@ -15,62 +15,85 @@ import "time"
// 01:30 resolves to 02:30; stepping from that with AddDate would carry
// 02:30 forward for the rest of the series' life while the record
// still said 01:30 (booking spec §4.1, r7 f1).
-func advance(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, bool) {
+type stepState uint8
+
+const (
+ stepIncomplete stepState = iota
+ stepCandidate
+ stepExhausted
+)
+
+func advance(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) {
+ if !validExpansionRule(r) || !inDateHorizon(cur) || !inDateHorizon(proto) {
+ return time.Time{}, stepIncomplete
+ }
+ if r.Freq == "" {
+ return time.Time{}, stepExhausted
+ }
+ next, state := nextCandidate(cur, proto, r, loc)
+ if state == stepCandidate && (!inDateHorizon(next) || !next.After(cur)) {
+ return time.Time{}, stepIncomplete
+ }
+ return next, state
+}
+
+func nextCandidate(cur, proto time.Time, r Rule, loc *time.Location) (time.Time, stepState) {
switch r.Freq {
case FreqDaily:
- return onDate(cur.AddDate(0, 0, r.Interval), proto, loc), true
-
+ if int64(r.Interval) > lastCivilDay-civilDay(cur) {
+ return time.Time{}, stepIncomplete
+ }
+ return candidateOnDay(civilDay(cur)+int64(r.Interval), proto, loc)
case FreqWeekly:
+ day := civilDay(cur)
if len(r.ByDay) == 0 {
- return onDate(cur.AddDate(0, 0, 7*r.Interval), proto, loc), true
+ if int64(r.Interval) > (lastCivilDay-day)/7 {
+ return time.Time{}, stepIncomplete
+ }
+ return candidateOnDay(day+7*int64(r.Interval), proto, loc)
}
- // Walk forward a day at a time to the next named weekday in an
- // ELIGIBLE week. Eligibility is measured from the SERIES' own
- // week, not from cur's: a fortnightly Monday-and-Wednesday
- // rule that starts on a Wednesday has its next Monday twelve
- // days later, and a search that only looked seven days ahead
- // from the Wednesday, then jumped a whole interval, landed on
- // the following Wednesday and never generated that Monday
- // (r8 f1). Bounded: the next eligible week is at most interval
- // weeks away, and a week has seven days.
- for step := 1; step <= 7*r.Interval+7; step++ {
- cand := cur.AddDate(0, 0, step)
- if !containsWeekday(r.ByDay, cand.Weekday()) {
- continue
+ k := daysSinceWeekStart(cur.Weekday(), r.WeekStart)
+ for off := 1; off <= 6-k; off++ {
+ if containsWeekday(r.ByDay, time.Weekday((int(cur.Weekday())+off)%7)) {
+ return candidateOnDay(day+int64(off), proto, loc)
}
- if r.Interval > 1 && weeksBetween(proto, cand, r.WeekStart)%r.Interval != 0 {
- continue
+ }
+ weekStart := day - int64(k)
+ if int64(r.Interval) > (lastCivilDay-weekStart)/7 {
+ return time.Time{}, stepIncomplete
+ }
+ weekStart += 7 * int64(r.Interval)
+ for off := 0; off < 7; off++ {
+ if containsWeekday(r.ByDay, time.Weekday((int(r.WeekStart)+off)%7)) {
+ return candidateOnDay(weekStart+int64(off), proto, loc)
}
- return onDate(cand, proto, loc), true
}
- return time.Time{}, false
-
case FreqMonthly:
- // Try successive months and skip any that cannot hold the
- // named date — never clamp. Bounded so a rule that can never
- // land (there is none in this subset, but a future one might)
- // terminates instead of spinning.
- for step := 1; step <= 120; step++ {
- base := firstOfMonth(cur, loc).AddDate(0, step*r.Interval, 0)
- if d, ok := monthlyDate(base, r, proto, loc); ok && d.After(cur) {
- return d, true
+ currentMonth := cur.Year()*12 + int(cur.Month()) - 1
+ for step := 1; step <= 4800; step++ {
+ if r.Interval > (9999*12+11-currentMonth)/step {
+ return time.Time{}, stepIncomplete
+ }
+ target := currentMonth + step*r.Interval
+ base := time.Date(target/12, time.Month(target%12+1), 1, 0, 0, 0, 0, time.UTC)
+ if d, ok := monthlyDate(base, r, proto, loc); ok {
+ return d, stepCandidate
}
}
- return time.Time{}, false
-
+ return time.Time{}, stepExhausted
case FreqYearly:
- for step := 1; step <= 40; step++ {
- cand := time.Date(cur.Year()+step*r.Interval, proto.Month(), proto.Day(),
- proto.Hour(), proto.Minute(), 0, 0, loc)
- // 29 February in a non-leap year rolls into 1 March, which
- // is a different day and therefore not an occurrence.
- if cand.Day() == proto.Day() && cand.Month() == proto.Month() {
- return cand, true
+ for step := 1; step <= 400; step++ {
+ if r.Interval > (9999-cur.Year())/step {
+ return time.Time{}, stepIncomplete
+ }
+ y := cur.Year() + step*r.Interval
+ if proto.Day() <= daysInMonth(y, proto.Month()) {
+ return time.Date(y, proto.Month(), proto.Day(), proto.Hour(), proto.Minute(), 0, 0, loc), stepCandidate
}
}
- return time.Time{}, false
+ return time.Time{}, stepExhausted
}
- return time.Time{}, false
+ return time.Time{}, stepIncomplete
}
// onDate puts proto's time of day onto d's civil date in loc.
@@ -104,8 +127,11 @@ func monthlyDate(base time.Time, r Rule, proto time.Time, loc *time.Location) (t
// a month, reporting false when the month has no such day — a fifth
// Friday exists in some months and not others.
func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *time.Location) (time.Time, bool) {
+ if n == 0 || n > 5 || n < -5 {
+ return time.Time{}, false
+ }
if n > 0 {
- first := time.Date(y, m, 1, hh, mm, 0, 0, loc)
+ first := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC)
delta := (int(wd) - int(first.Weekday()) + 7) % 7
day := 1 + delta + (n-1)*7
if day > daysInMonth(y, m) {
@@ -113,7 +139,7 @@ func nthWeekday(y int, m time.Month, n int, wd time.Weekday, hh, mm int, loc *ti
}
return time.Date(y, m, day, hh, mm, 0, 0, loc), true
}
- last := time.Date(y, m, daysInMonth(y, m), hh, mm, 0, 0, loc)
+ last := time.Date(y, m, daysInMonth(y, m), 0, 0, 0, 0, time.UTC)
delta := (int(last.Weekday()) - int(wd) + 7) % 7
day := last.Day() - delta + (n+1)*7
if day < 1 {
@@ -127,10 +153,6 @@ func daysInMonth(y int, m time.Month) int {
return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
-func firstOfMonth(t time.Time, loc *time.Location) time.Time {
- return time.Date(t.Year(), t.Month(), 1, t.Hour(), t.Minute(), 0, 0, loc)
-}
-
func containsWeekday(days []time.Weekday, wd time.Weekday) bool {
for _, d := range days {
if d == wd {
@@ -148,16 +170,6 @@ func civil(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
-// weeksBetween counts calendar weeks between two days, for INTERVAL on
-// a weekly rule. Weeks start on wkst — the rule's WKST, Monday by the
-// RFC's default — and where a week starts decides which week a Sunday
-// or a Monday is in.
-func weeksBetween(a, b time.Time, wkst time.Weekday) int {
- da := civil(a).AddDate(0, 0, -daysSinceWeekStart(a.Weekday(), wkst))
- db := civil(b).AddDate(0, 0, -daysSinceWeekStart(b.Weekday(), wkst))
- return int(db.Sub(da).Hours() / (24 * 7))
-}
-
func daysSinceWeekStart(d, wkst time.Weekday) int {
return (int(d) - int(wkst) + 7) % 7
}
@@ -168,5 +180,5 @@ func daysSinceWeekStart(d, wkst time.Weekday) int {
// Dublin is 47 hours and TWO days, and dividing by 24 made it one, so
// every later occurrence ended a day early (booking spec §4.1, r4 B2).
func daysBetween(start, end time.Time) int {
- return int(civil(end).Sub(civil(start)).Hours() / 24)
+ return int(civilDay(end) - civilDay(start))
}
diff --git a/internal/calendar/recurrence_bounds_probe_test.go b/internal/calendar/recurrence_bounds_probe_test.go
new file mode 100644
index 0000000..600bd72
--- /dev/null
+++ b/internal/calendar/recurrence_bounds_probe_test.go
@@ -0,0 +1,84 @@
+package calendar
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+)
+
+func TestWeeklyLargeIntervalFinishesPromptly(t *testing.T) {
+ if os.Getenv("CALENDAR_WEEKLY_BOUND_PROBE") == "1" {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ r, err := ParseRule("FREQ=WEEKLY;INTERVAL=100000000;BYDAY=MO")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, state := advance(proto, proto, r, time.UTC)
+ if state != stepIncomplete {
+ t.Fatalf("next=%v state=%v want incomplete horizon", got, state)
+ }
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestWeeklyLargeIntervalFinishesPromptly$", "-test.count=1")
+ cmd.Env = append(os.Environ(), "CALENDAR_WEEKLY_BOUND_PROBE=1")
+ out, err := cmd.CombinedOutput()
+ if ctx.Err() != nil {
+ t.Fatal("weekly BYDAY expansion exceeded the bounded subprocess deadline")
+ }
+ if err != nil {
+ t.Fatalf("probe: %v\n%s", err, out)
+ }
+}
+
+func TestCivilDateDifferencesDoNotSaturateAt292Years(t *testing.T) {
+ start := time.Date(1600, 1, 1, 9, 0, 0, 0, time.UTC)
+ end := time.Date(2000, 1, 1, 9, 0, 0, 0, time.UTC)
+ if got := daysBetween(start, end); got != 146097 {
+ t.Errorf("civil days=%d want146097", got)
+ }
+ if got := daysBetween(end, start); got != -146097 {
+ t.Errorf("reverse civil days=%d want-146097", got)
+ }
+}
+
+func TestYearlyAdvanceNeverWrapsBackwards(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ for _, interval := range []int{int(^uint(0) >> 1), int(^uint(0)>>1) / 2, int(^uint(0)>>1) / 3} {
+ next, state := advance(proto, proto, Rule{Freq: FreqYearly, Interval: interval}, time.UTC)
+ if state == stepCandidate && !next.After(proto) {
+ t.Errorf("interval=%d next=%v is not later than %v", interval, next, proto)
+ }
+ }
+}
+
+func TestStrictExpansionDoesNotCallInvalidInputComplete(t *testing.T) {
+ r, _ := ParseRule("FREQ=DAILY")
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).Unix()
+ to := from + 86400
+ for _, c := range []struct{ start, end, zone string }{
+ {"2026-09-07T09:00", "2026-09-07T10:00", "Not/AZone"},
+ {"invalid", "2026-09-07T10:00", "UTC"},
+ {"2026-09-07T09:00", "invalid", "UTC"},
+ } {
+ if _, complete := ExpandComplete(c.start, c.end, c.zone, false, r, "", from, to); complete {
+ t.Errorf("invalid input marked complete: %+v", c)
+ }
+ }
+}
+
+func TestFourCenturyWeeklyOccurrenceIsNotSilentlyLost(t *testing.T) {
+ r, err := ParseRule("FREQ=WEEKLY;INTERVAL=20871;BYDAY=SA;COUNT=2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ from := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC).Unix()
+ got, complete := ExpandComplete("1600-01-01T09:00", "1600-01-01T10:00", "UTC", false, r, "", from, to)
+ if !complete || len(got) != 1 || got[0].Local != "2000-01-01T09:00" {
+ t.Fatalf("occurrences=%+v complete=%v; want the second occurrence on 2000-01-01", got, complete)
+ }
+}
diff --git a/internal/calendar/recurrence_horizon_test.go b/internal/calendar/recurrence_horizon_test.go
new file mode 100644
index 0000000..2e43266
--- /dev/null
+++ b/internal/calendar/recurrence_horizon_test.go
@@ -0,0 +1,95 @@
+package calendar
+
+import (
+ "testing"
+ "time"
+)
+
+func TestLargeRepresentableWeeklyIntervalIsExact(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=WEEKLY;INTERVAL=400000;BYDAY=MO")
+ next, state := advance(proto, proto, r, time.UTC)
+ want := time.Date(9692, 10, 27, 9, 0, 0, 0, time.UTC)
+ if state != stepCandidate || !next.Equal(want) || next.Weekday() != time.Monday {
+ t.Fatalf("next=%v state=%v want=%v", next, state, want)
+ }
+}
+
+func TestHugeIntervalsRefuseWithoutOverflow(t *testing.T) {
+ proto := time.Date(2026, 9, 7, 9, 0, 0, 0, time.UTC)
+ for _, freq := range []string{FreqDaily, FreqWeekly, FreqMonthly, FreqYearly} {
+ for _, interval := range []int{int(^uint(0) >> 1), int(^uint(0)>>1) / 2} {
+ next, state := advance(proto, proto, Rule{Freq: freq, Interval: interval}, time.UTC)
+ if state != stepIncomplete {
+ t.Errorf("%s interval%d next%v state%v", freq, interval, next, state)
+ }
+ }
+ }
+}
+
+func TestHorizonAllowsItsLastDayAndRefusesTheNext(t *testing.T) {
+ proto := time.Date(9999, 12, 30, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=DAILY")
+ next, state := advance(proto, proto, r, time.UTC)
+ if state != stepCandidate || next.Format(LayoutLocal) != "9999-12-31T09:00" {
+ t.Fatalf("last day=%v state=%v", next, state)
+ }
+ if _, state = advance(next, proto, r, time.UTC); state != stepIncomplete {
+ t.Fatal("accepted date beyond four-digit record horizon")
+ }
+ weekly, _ := ParseRule("FREQ=WEEKLY;BYDAY=SA")
+ if _, state = advance(next, next, weekly, time.UTC); state != stepIncomplete {
+ t.Fatal("accepted weekly day beyond horizon")
+ }
+}
+
+func TestCountCompletionDoesNotRequireAnotherRepresentableStep(t *testing.T) {
+ from := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC).Unix()
+ to := time.Date(10000, 1, 2, 0, 0, 0, 0, time.UTC).Unix()
+ r, _ := ParseRule("FREQ=YEARLY;INTERVAL=99999999;COUNT=1")
+ got, complete := ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "UTC", false, r, "", from, to)
+ if len(got) != 1 || !complete {
+ t.Fatalf("completed COUNT=%v complete=%v", got, complete)
+ }
+ r.Count = 0
+ if _, complete = ExpandComplete("9999-12-31T09:00", "9999-12-31T10:00", "Pacific/Kiritimati", false, r, "", from-86400, to); complete {
+ t.Fatal("horizon is not proof of completion, including zones east of UTC")
+ }
+}
+
+func TestCalendarExhaustionDoesNotUseZoneNormalization(t *testing.T) {
+ proto := time.Date(2026, 2, 1, 9, 0, 0, 0, time.UTC)
+ r, _ := ParseRule("FREQ=MONTHLY;INTERVAL=12;BYMONTHDAY=31")
+ if next, state := advance(proto, proto, r, time.UTC); state != stepExhausted {
+ t.Fatalf("impossible February31=%v state=%v", next, state)
+ }
+ for _, ordinal := range []int{int(^uint(0) >> 1), -int(^uint(0) >> 1)} {
+ r := Rule{Freq: FreqMonthly, Interval: 1, Ordinal: ordinal, OrdinalDay: time.Monday}
+ if _, state := advance(proto, proto, r, time.UTC); state != stepExhausted {
+ t.Fatalf("impossible ordinal%d state%v", ordinal, state)
+ }
+ }
+ leap := time.Date(2000, 2, 29, 9, 0, 0, 0, time.UTC)
+ r, _ = ParseRule("FREQ=YEARLY;INTERVAL=100")
+ if next, state := advance(leap, leap, r, time.UTC); state != stepCandidate || next.Year() != 2400 || next.Day() != 29 {
+ t.Fatalf("century leap skip=%v state=%v", next, state)
+ }
+}
+
+func TestInvalidInputsCannotBecomeCompleteAtCountOrWindow(t *testing.T) {
+ from := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).Unix()
+ for _, rule := range []Rule{{Freq: FreqDaily, Interval: 0}, {Freq: "HOURLY", Interval: 1}, {Freq: FreqWeekly, Interval: 1, ByDay: []time.Weekday{9}}, {Freq: FreqMonthly, Interval: 1, MonthDay: 32}} {
+ if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, rule, "", from, from+86400); complete {
+ t.Errorf("invalid direct rule complete: %+v", rule)
+ }
+ }
+ r, _ := ParseRule("FREQ=DAILY;COUNT=1")
+ for _, end := range []string{"invalid", "2026-09-07T08:00"} {
+ if _, complete := ExpandComplete("2026-09-07T09:00", end, "UTC", false, r, "", from, from+86400); complete {
+ t.Errorf("invalid end complete: %s", end)
+ }
+ }
+ if _, complete := ExpandComplete("2026-09-07T09:00", "2026-09-07T10:00", "UTC", false, r, "", from+86400, from); complete {
+ t.Fatal("reversed window complete")
+ }
+}
diff --git a/internal/calendartest/recurrence_invalid_test.go b/internal/calendartest/recurrence_invalid_test.go
new file mode 100644
index 0000000..847300e
--- /dev/null
+++ b/internal/calendartest/recurrence_invalid_test.go
@@ -0,0 +1,30 @@
+package calendartest
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+func TestStrictRangeRejectsUnreadableBusySeries(t *testing.T) {
+ for _, field := range []string{"tz", "start_local", "end_local"} {
+ t.Run(field, func(t *testing.T) {
+ app, session := signedIn(t, "someone@example.com")
+ createEvent(t, app.Handler(), session, url.Values{"title": {"fixture"}, "start": {testAt(1, "09:00")}, "end": {testAt(1, "10:00")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}})
+ if _, err := app.DBForTest().Exec("UPDATE events SET "+field+" = ? WHERE rrule <> ''", "invalid"); err != nil {
+ t.Fatal(err)
+ }
+ person := app.PersonByAddressForTest(t, "someone@example.com")
+ from, to := window(t, 1, 3)
+ if _, err := app.StoreForTest().EventsInRange(context.Background(), person.ID, from, to); err != nil {
+ t.Fatalf("tolerant view: %v", err)
+ }
+ if _, err := app.StoreForTest().EventsInRangeStrict(context.Background(), person.ID, from, to); !errors.Is(err, calendar.ErrIncompleteExpansion) {
+ t.Fatalf("strict result=%v want incomplete expansion", err)
+ }
+ })
+ }
+}