Files
xarmian e4a5c20dd0 feat(schedule): internal/schedule, RFC 5545 RRULE expansion with pinned/floating time and DST rules (TASK-4100) (#2336)
* feat(schedule): wall times, DST resolution, typed errors and a strict RRULE parser (TASK-4100 1/2)

Commit 1 of 2 of the internal/schedule package (pure, no store/HTTP).

- wall.go: Wall, ParseWall, String, Resolve. Resolve does not use
  time.Date's gap/overlap choice: a gap takes the offset before it
  (shifts forward), an overlap takes the earlier instant.
- errors.go: ErrMalformed, MalformedError with typed Codes, ErrViewRequired.
- rule.go: strict RRULE parser (ParseRule, canonical String, combination
  and satisfiability checks, Validate against Start). FREQ=SECONDLY is
  refused (CodeUnsupported); a 512-byte, INTERVAL 1e6, years 1970-2200
  limit set applies.
- doc.go: the DST and sub-daily policy, including the population table.

Size (wc -l): production 652 raw (doc 44, errors 82, wall 106, rule 420),
515 excluding comments and blank lines; tests 388 (resolve 107, rule 219,
wall 59, tzdata 3).

RED controls: Resolve overlap taking the later instant fails TestResolve
(ny-overlap-first-minute, ny-overlap-mid, london-overlap,
lord-howe-overlap); removing the SECONDLY refusal fails
TestMalformedRulesTable/FREQ=SECONDLY.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* feat(schedule): the expansion engine on rrule-go, caps, shorthands and RFC 5545 conformance (TASK-4100 2/2)

Adds github.com/teambition/rrule-go v1.8.2 (go.mod +1 line, go.sum +2) behind
a wrapper. The library is a naive wall-clock engine fed UTC-labelled walls;
Resolve maps each wall to an instant. Bare HOURLY/MINUTELY step in elapsed
time with a fast-forward; everything else is wall clock with collapse.

- spec.go: Spec, Compiled, Compile, Occurrence. Every refusal is a
  *MalformedError; no library text can surface.
- engine.go: NextAfter, Between, StartSynced. Resolve is not monotonic inside
  a DST gap, so the walk is bounded on the WALL reading (48 h margin), never on
  "first instant >= to"; results are sorted, de-duplicated by instant, strictly
  increasing, Between half-open.
- caps.go: Limits, DefaultLimits, CapError, ErrCapHit, the CapTime guard and
  the CapScan precheck. The library iterator is used, never All().
- shorthand.go: the eight shorthands and NormalizeRule.
- CLAUDE.md: one Key Directories line.

Tests: the 43 RFC 5545 3.8.5.3 rows and 30 DST/edge rows run through the real
package, the DST population table, caps, floating, Next/Between, shorthands,
TestOutputStrictlyIncreasingAcrossDST and TestCompileNeverReturnsLibraryError.

Erratum: the HOURLY example with UNTIL=19970902T170000Z is RFC 5545 erratum
3883 (170000Z is 13:00 EDT, so 15:00 EDT is past UNTIL). It stays in the suite
as an expected-different row asserting 09:00 and 12:00 EDT, never a skip.
Friday-made-on-Tuesday (ruling Q1): the Tuesday is not emitted and
StartSynced() is false.

Size (whole unit, commit 1 included), production only, code lines excluding
comments and blanks / raw: caps.go 81/109, doc.go 1/44, engine.go 225/265,
errors.go 60/82, rule.go 376/420, shorthand.go 41/53, spec.go 103/128,
wall.go 78/106; total 965 code, 1207 raw. Test files 1640 raw (row data
included).

govulncheck v1.2.0 (local binary, DB 2026-10-27) on ./internal/schedule/: No
vulnerabilities found.

RED controls, each applied then reverted: (a) no adjacent-instant dedupe fails
the gap-collision rows, TestBetweenDedupesByInstant and
TestOutputStrictlyIncreasingAcrossDST; (b) local-zone times fed to the library
fail dst-daily-ny-gap and the other gap rows; (c) emitting a non-matching
DTSTART fails TestNextAfterUnsyncedStartSkipsStart; (d) skipping the final
sort fails TestOutputStrictlyIncreasingAcrossDST (daily-rdate-inversion).

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* chore(schedule): vendor teambition/rrule-go v1.8.2 verbatim into internal/schedule/internal/rrule (TASK-4100 H1, import)

Byte-for-byte copy of rrule.go, rruleset.go, str.go, util.go and LICENSE
(upstream 429348ca4477691b08edb0200b7f837edd32414c), plus PROVENANCE.md.
Not yet imported; no go.mod change. sha256, module cache vs vendored:

3af19c18...8fda  rrule.go    (cache == vendored)
89f1c9ca...ad8   rruleset.go (cache == vendored)
0dc2d364...7d96  str.go      (cache == vendored)
d95c565b...0173  util.go     (cache == vendored)
2c5aa800...470f  LICENSE     (cache == vendored)

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* fix(schedule): bound rrule expansion with an in-loop step budget and drop the abandoned goroutine (TASK-4100 H1)

- internal/schedule now imports the vendored internal/rrule; go.mod and
  go.sum drop github.com/teambition/rrule-go (go mod tidy).
- Vendored generator: a step budget (RRule.SetBudget / Exhausted) counted
  on every period-loop and inner-skip iteration, matched or not. On
  exhaustion the iterator finishes and records it; no panic.
- Limits.MaxSteps (default 200000) feeds it; exhaustion is a CapScan,
  never a partial result. A 200k-step call costs about 13ms.
- guard no longer starts a goroutine: all work runs in the caller.
  CapTime is now a wall-clock check between yields.
- probeSynced treats an exhausted probe as unsynced.
- doc.go says why the library is vendored.
- Tests: TestBudgetFeb29Minutely, TestBudgetUnsatisfiable (goroutine
  count, wall time, CapScan); TestCapTimeGuard rewritten in-goroutine.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* test(schedule): prove no goroutine outlives an expansion call (TASK-4100 H1)

TestNoGoroutineOutlivesCall: MaxDuration 1ms, MaxSteps 2M on the
unsatisfiable rule (about 65 ns a step, so about 130 ms a call, under the
roughly 2.9M-step walk to year 9999, so CapScan still trips first). 20
calls, then NumGoroutine is read with no settle and must equal the count
before.

Mutant history: restoring the goroutine + select + time.After path in
guard stayed green alone at 58bc2a9b3, because the budget bounds the
abandoned goroutine. With this test it is RED:
  budget_test.go:116: goroutines 2 -> 22 right after 20 calls; none may outlive a call

go.mod and go.sum have zero net diff against origin/main 3e5cde148 (the
dependency was added in 08a09e807 and removed in 58bc2a9b3), so no nix
vendorHash heal is owed.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* fix(schedule): instant-bound UNTIL, a wall-margin instance stop, a saturating scan estimate and early exhaustion (TASK-4100 r1)

Fixes from review round 1, each with a test and a mutant that goes RED:

- M1 Between's constant +200 early stop is replaced by the wall-margin stop:
  once MaxInstances+1 distinct instants are held, the walk goes on until its
  key passes the bound of the (MaxInstances+1)-th smallest. Tests:
  TestBetweenGapPopulationKeepsEarlierRDate (the New York repro),
  TestBetweenEqualsChainedNextAfter at MaxInstances 5, 50, 500, 100000.
  Mutant (restore +200): "first 2025-03-09 07:30:00 +0000 UTC; want ... 07:10:00".
- M2+L1 a Z UNTIL is an instant bound on the wall-clock path: the library gets
  a generous wall, each resolved rule instant is filtered At <= UNTIL (RDATEs
  exempt), and probeSynced runs in the effective zone. Tests:
  TestUntilZIsAnInstantBound (gap, overlap, inclusive, two all-day rows),
  TestUntilZDoesNotBoundRDates, TestStartSyncedTokyoZUntil.
  Mutants (wall compare; UTC probe): "gap-emits-past: got [... 2025-03-09T07:50:00Z]";
  "StartSynced = false; the rule produces Start".
- L2 checkScan's period*INTERVAL saturates. Test: TestCapScanIntervalProductSaturates
  (213504 and 150000). Mutant (plain multiply): "INTERVAL=213504 NextAfter: cap hit: scan limit".
- L4 a rule whose UNTIL is spent before the query start is not estimated and
  not walked (the library rule is dropped, RDATEs still run); a COUNT rule skips
  the estimate and ends on its own inside MaxSteps. Test: TestExhaustedRuleIsNotACapScan.
  Mutant (no early check): "NextAfter: ok=false err=schedule: cap hit: scan limit".
- L3c the dangling fragment in rfc5545_rows_test.go's header comment.
- H1 reachability: Compile refuses an HOURLY/MINUTELY rule made only of
  INTERVAL, BYHOUR and BYMINUTE whose steps provably never land on them
  (CodeUnsatisfiable), unless an RDATE is present. Any other part, COUNT or
  UNTIL leaves it alone. TestReachabilityCheckIsExact: 5760 cells, 2626
  refusals, 0 disagreements with the vendored engine run directly.
  Mutant (drop the modulus): "Compile refused=false but the engine fires=false".

Size, production code (comments and blanks excluded) / raw, vendored files
excluded: 968 -> 1058 code, 1345 raw. caps 75/110, doc 1/52, engine 276/334,
errors 60/82, rule 421/477, shorthand 41/53, spec 106/131, wall 78/106.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* fix(schedule): bound only the no-match stretch (CapStall), keep prefetched answers, and prove the bounds over the conformance grid (TASK-4100 r2)

R2-H1: the vendored step counter restarts at every candidate the generator
appends, so MaxSteps bounds a no-match stretch only. Total work stays bounded
by checkScan (MaxScan) and the CapTime check between yields; candidates our
wrapper discards (window, Z UNTIL, EXDATE) cannot loop unboundedly because
each one is a yield. Tests: TestStallDenseRules (the four reported rules past
their former first-cap day, plus 3 years out, exact expected values computed
independently), TestStallFilteredCandidatesBounded. Mutant: remove the two
resets -> TestStallDenseRules RED ("cap hit: stall limit 200000; want
2025-05-11 00:15:00").

R2-L1: a stall that follows a held occurrence no longer turns it into an
error. RRule.Reached() reports where the expander stood; NextAfter and
Between keep their answer when that is past the bound of the window. Not with
RDATEs (the set drops the stalled rule and may reorder). Test:
TestStallPrefetchKeepsAnswer. Mutant: held=false -> RED ("cap hit: stall limit
2000; want 2104-02-29").

R2-L2: new Kind CapStall (Limit = MaxSteps); CapScan keeps one meaning (Limit =
MaxScan). Documented in caps.go and doc.go. Test: TestStallKinds. Mutant: map
stall to CapScan -> RED ("scan limit 200000; want a stall CapError").

R2-L3: budget_test.go asserts steps, kinds and goroutine counts, no wall
clock; the fabricated CapError closure is gone; TestNoGoroutineOutlivesCall
uses MaxSteps 200k (2 s under -race). m2 mutant (goroutine + select +
time.After in guard) still RED: "goroutines 2 -> 21 right after 20 calls".

Instrument: TestBoundsPropertyOverConformanceGrid. 73 conformance rows (43 RFC
+ 30 extra) x 2 query points, and 3,134 satisfiable grid cells, return what the
vendored engine returns with no budget (NextAfter and Between): 0
disagreements, 0 CapScan. 2,626 unsatisfiable cells: CapStall, Limit
MaxSteps. Default Limits but MaxDuration (a wall clock, lifted for -race).

Compile, slowest: 6.3 ms across the grid and the conformance rows (both noise
on a cheap rule); sparse YEARLY/MONTHLY at most 5.5 ms; the bound is one
MaxSteps probe, about 13 ms.

Sizes (internal/schedule, vendored excluded): code 1,068, raw 1,365. Vendored
delta vs 25ea7f68f: 43 lines.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* fix(schedule): merge rule, RDATE and EXDATE streams ourselves with a conservative stall position and one finality rule (TASK-4100 r3 design)

r3 found two Mediums in the stall logic (M1: the Reached() gate dropped an
occurrence at a small MaxSteps; M2: an RDATE starved behind a stalled rule at
default limits) and the stop rule fired: both came from our correctness resting
on the vendored Set's and generator's control flow. Design D1-D4, as ruled.

Order of work: the instrument first (TestNeverAPartialAnswer), shown RED on
ace5b29e6 for M1 and M2 (quoted in ~/.cache/rook/4100-f-red.log), then the
engine.

D1: the engine merges the rule generator (budgeted, alone), a sorted RDATE
slice and an EXDATE set (exact instant, in the candidates' own time space). An
RDATE equal to a rule occurrence yields once. A stalled rule is a stream state.
CapTime is checked on every pass of the merge loop, so a run of skipped EXDATEs
is bounded (engine.go:239). rrule.Set is the test oracle only.
D2: the generator reports StallPos, the start of the first period not fully
examined, set in one place (spend). Reached() and the held-answer machinery are
gone. Branches that can stall and the P each reports: period-loop top, by FREQ:
YEARLY Jan 1 00:00; MONTHLY day 1 00:00; WEEKLY and DAILY 00:00 of the day;
HOURLY hh:00:00; MINUTELY hh:mm:00; SECONDLY hh:mm:ss. Skip loops (HOURLY,
MINUTELY, SECONDLY): the current point plus INTERVAL at the FREQ unit.
D3: one finality rule. Stuck at P is final iff P is past the caller's bound
(the answer's bound for NextAfter, hi + wallMargin for Between); otherwise a
CapStall. merger.stuck is the only origin of CapStall.
D4: TestNeverAPartialAnswer: 73 rows plus sampled grid cells (every 23rd; every
149th under -race: coprime to every axis, since 24 and 150 aliased and dropped
values; the test asserts every axis value is run), M1 and M2 named cases (UTC, Tokyo, New York), MaxSteps in
{1,2,5,50,1000,default}, RDATE none/before/after/coincide, EXDATE
none/first/on-the-RDATE, three query points. Oracle: the vendored Set,
unbudgeted. Run: 330 cases, 27124 permitted CapStalls, 47192 answers equal to
the oracle. Rows run at +0, +400d and +3y. TestMergeYieldsEachInstantOnce holds the dedupe below the settle
that hides it from the public calls.

M2 measured: P lands at 2298-10-17, so the RDATE 2025-06-01T09:00 lies below P
and is returned by NextAfter and Between.

Mutants (each RED, reverted): f1 P inside the unexamined period (M1 shape); f2
stalled rule as an immediate call error (M2 shape); f3 no dedupe (merge test);
f4 EXDATE compared on wall (dst-hourly-ny-overlap); m2 goroutine guard restored
(goroutine count 2 -> 15).

Tests adjusted to the new semantics: budget_test (Between inside the examined
stretch is an empty final answer; a wider window is the CapStall),
TestNoGoroutineOutlivesCall (a final answer is not an error), bounds_test (walk
signature; refWindow moved to oracle_test.go on the vendored Set).

L2: caps.go comment corrected; a COUNT walk is bounded by MaxDuration and
MaxInstances, not by MaxSteps.

Size: production code 1116 lines (was 1068; over the 1,100 cap by 16), raw
1435. Vendored delta vs 25ea7f68f: 41 changed lines (was 43).

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL

* docs(schedule): correct the bounds claims and the vendored-delta record (TASK-4100 r4)

Prose only; no Go code, test logic or behaviour changes.

- caps.go: three "bounded by" claims were false after the per-candidate
  step reset (r2). MaxDuration is the only bound on a whole call; MaxScan
  refuses a far wall-clock query except for COUNT and spent-UNTIL rules;
  MaxInstances counts only what Between returns; MaxSteps ends a no-match
  stretch. (r4 H-L2, plus caps.go:21 and the guard comment, found by the
  CONVE-18 grep.)
- PROVENANCE.md: list the whole vendored delta (budget, per-candidate
  steps reset, stall/StallPos/spend), and state that rruleset.go, str.go,
  util.go and LICENSE are byte-identical to upstream (sha256 matches the
  table). (r4 H-L3)
- TestNeverAPartialAnswer: say that its oracle shares the vendored
  generator, so recurrence correctness is the conformance table's job.
  (r4 C-L1)

Built with Codex (gpt-6.1-sol) from a spec; the PROVENANCE verification
line is mine.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* test(schedule): check the stall position against calendar arithmetic (TASK-4100 H')

The false-cap half of TestNeverAPartialAnswer judged a CapStall by the
generator's own StallPos, so a P that is too LOW was never caught: two
one-period-lower mutants of spend() left it green (r4, H step 1).

TestStallPosClosedForm checks P with RFC 5545 calendar arithmetic and no
generator call, on never-firing stretches from DTSTART, at budgets 1, 2, 3
and 5:
- YEARLY/MONTHLY/WEEKLY/DAILY (one charge per period, rrule.go:615):
  P == the period start S*INTERVAL periods on (WEEKLY aligned to WKST).
- HOURLY/MINUTELY with no day-level BY part (one charge per inner step,
  :774/:795): P == DTSTART + S*INTERVAL units. Includes New York cases
  across spring-forward and fall-back. These run on the wall-clock path,
  where the generator sees naive walls, so they confirm the zone never
  reaches it; they do not test DST handling inside the generator.
- Anything else (a filtered day jumps for free, :770/:791): only
  P >= DTSTART + floor(S/2)*INTERVAL units. This floor cannot see a P one
  INTERVAL low; the blind spot is stated in the test.
Equality catches a too-high P as well as a too-low one.

Acceptance (~/.cache/rook/4100-Hp/acc-*.log, line 1 carries the sha):
mutant A (period-loop top one period low) fails all 7 fixed cases at every
S; mutant B (inner loop one interval low) fails all 7 unit cases at every
S; floor mutant C (top-of-loop P = DTSTART) fails 3 of 4 floor cases at
S=2; the unmutated tree is green.

Built with Codex (gpt-6.1-sol) from a spec; reviewed and run here.
Test-only: production size unchanged (1,116 / 1,150).

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* fix(schedule): refuse rules denser than 10,000 candidates per period, and parse integers by RFC 5545 grammar (TASK-4100 final round)

A 465-byte rule (YEARLY with every BYDAY, BYHOUR, BYMINUTE and BYSECOND
value) compiled in ~15 s and ~4.3 GiB, and NextAfter overran MaxDuration
~40x: the generator fills a whole period's candidates before its first
yield, and no cap is checked inside that (final-round H1, reproduced).

- periodDensity: an upper bound on one FREQ period's candidates from
  RFC 5545 3.3.10's expand/limit table (days per period x the expanding
  time parts). ParseRule refuses a rule over maxPeriodCandidates (10,000)
  with CodeRange before any expansion, so Compile's probe never runs.
- RFC 5545 integer grammar: a sign only where the grammar allows one
  (BYMONTHDAY, BYYEARDAY, BYWEEKNO, BYSETPOS, BYDAY ordinals) and the
  grammar's digit widths. INTERVAL=+2, COUNT=+3, BYHOUR=-0 and BYMONTH=001
  are now CodeSyntax; COUNT=-1 and INTERVAL=-2 move from CodeRange to
  CodeSyntax for the same reason.
- Prose made true: MaxDuration bounds a call to within one period's
  expansion, which the density cap limits; a far COUNT query ends in
  CapTime; the package reads the clock for that deadline; Wall is not an
  occurrence's identity on the elapsed path.
- Tests: the bound >= the generator's real per-period count for every
  FREQ x part (peak/bound 1.000 on the sampled cases); the cheap refusal
  (<50 ms, <1 MiB); a census of conformance rows, satisfiable grid cells
  and real-world dense shapes; the grammar; Pacific/Apia 2011-12-30.

Evidence (seat-run, not the builder's): ~/.cache/rook/4100-F1/tests.log
(full package ok), tests-v.log, threshold.log (worst admitted rule,
density 9,882: Compile 0.88 ms / 964 KiB; NextAfter equals the oracle at
default Limits with 42.9x-142x margin under MaxDuration), density-mut.log
(365-day and dropped-BYMINUTE mutants both red).

Size: production code 1,116 -> 1,150 (cap 1,150); raw 1,441 -> 1,499,
over the 1,450 raw cap; per-file numbers are on the trail for ruling.

Built with Codex (gpt-6.1-sol) from a code-only spec; tested here.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* docs(schedule): state the UNTIL type exception, the density refusal and MaxDuration's real overrun (TASK-4100 3a)

Comment-only, per the lead's 3a final-round ruling:
- doc.go: ParseRule refuses what RFC 5545 forbids EXCEPT an UNTIL whose
  value type differs from DTSTART's, which is accepted as Until defines it
  (C-M1, option B), and the refusal list names the 10,000-candidate density
  cap (H-L3).
- caps.go: MaxDuration is checked between yields, so a call can overrun it by
  one period expansion or one MaxSteps no-match stretch, and MaxSteps has no
  ceiling (H-L1).

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS

* chore(lint): exclude upstream rrule-go's lno1wkst dead store, narrowly (TASK-4100)

golangci-lint's ineffassign flags `lno1wkst = 0` in the vendored
internal/schedule/internal/rrule/rrule.go. The line is upstream v1.8.2,
verbatim since the import commit. Exclude that one finding (path, linter
and text) rather than edit upstream code or unlint the whole package, so
every other linter still checks our budget/stall delta in the same file.
PROVENANCE.md records the exclusion.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-28 18:14:23 -04:00
..