0de152fa

ical: refuse an out-of-range rule part value

The last of the parser findings. Nine numeric rule parts accepted out-of-range values and handed back a `RecurrenceRule` that fails its own `Validity`:

| rule part | accepted, invalid |
| --- | --- |
| `INTERVAL` | `0` |
| `BYSECOND` | `61` |
| `BYMINUTE` | `60` |
| `BYHOUR` | `24` |
| `BYMONTHDAY` | `32`, `0` |
| `BYYEARDAY` | `367`, `0` |
| `BYWEEKNO` | `54`, `0` |
| `BYSETPOS` | `367`, `0` |
| `BYDAY` | `54MO` |

That breaks the invariant the rest of the codebase leans on: a value that came out of a parser is supposed to be valid.

## Why nothing noticed

`recurrenceRulePartSpec` already has a `parses only valid things` property, which looks like it should have caught this. It renders an already-valid value and feeds *that* back, so the parser only ever sees text some valid value produced — out-of-range text never arrives. Hence the new `recurrenceRulePartRefusesSpec`, which hands it text directly.

## The range is not restated

Each of these types already declares its range in its `Validity` instance, so `requireInRange` asks that:

```haskell
requireInRange name val part =
  if isValid part
    then pure part
    else unfixableError $ RecurrenceRulePartOutOfRange name val
```

Tighten a `Validity` later and the parser follows automatically. That is also **why this had to come last**: the check inherits whatever `Validity` says, so a wrong bound becomes a wrong refusal. Before #33 and #35 this would have started refusing `COUNT=100` and `BYDAY=6MO`, both ordinary values.

## Two decisions worth reviewing

**Refused, not repaired.** The spec says which values are valid but not what to do with one that is not. Dropping the value would empty the rule part — and for a part that *limits* rather than *expands*, an empty part means no restriction at all, so a forbidden value would widen the recurrence set. That is the same shape as the BYDAY widening fixed in #32. `BYMONTH` already refused out-of-range values, so refusing matches what the module did anyway.

I had earlier leaned toward a fixable error that ignores the part, and retract that: the RFC's "MUST be ignored" sentence is about `BYSECOND`/`BYMINUTE`/`BYHOUR` appearing with a DATE-valued `DTSTART`, not about out-of-range values, so it does not license ignoring them.

**Per rule part, not per rule.** `Validity RecurrenceRule` also rules on *combinations* — a numeric `BYDAY` at a frequency that forbids one, `BYWEEKNO` outside `YEARLY`, `BYSETPOS` without a companion. Those are fixable errors raised while recurring, and checking the whole rule here would refuse them outright and undo #32. So the check is on the part only.

The check lives in the parsers rather than in `parseRecurrenceRule`, so `recurrenceRulePartP` cannot hand back an invalid value either. It is the primitive; the invariant belongs there.

## `BYDAY=54MO` is new

Before #35 it was refused for the wrong reason — the ordinal was read one digit at a time, so `54MO` failed on `"4MO"` rather than for being out of range. #35 taught it to read two digits, which dropped `54MO` into this same hole. This closes it.

## Checks

`ical-gen` 1088 → 1109 passing, `ical-recurrence-gen` 301, both 0 failing. `nix flake check` passes.

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 1s 1s 1s 0s
Eval 14s 11s 11s 26s 13s
Build 25s 4m52s 12m47s 13m13s 7m54s
Suite 0s 5m05s 13m13s 13m13s 8m08s

Timeline

0s9m10m11m12m13m