fix(caldav): patch the master component, and surface RECURRENCE-ID verbatim #17

Merged
jlxq0 merged 1 commit from fix-master-component into main 2026-08-26 09:04:25 +00:00
Owner

Refs #16. A live defect found while assessing the recurrence proposal, plus the instance metadata that everything else in that sequence depends on.

The defect

patch_ics and parse_ical_event both anchored on the first BEGIN:VEVENT. A calendar object for a recurring series holds the master and one VEVENT per override, and RFC 5545 does not require the master first.

Measured before it was fixed, with a scratch test against an override-first object:

PROBE parse_ical_event summary=Some("Override") rrule=None recurrence_id=Some("2026-09-01T01:00:00Z")
PROBE patched SUMMARY:Renamed      <- the override
PROBE patched SUMMARY:Master       <- untouched

So update_event on such a series renamed one occurrence, reported success, and returned recurrence_rule: null for a series that has one. A client reading that answer stops treating the event as recurring.

master_event_start now selects the component with no RECURRENCE-ID, and it is the single place that decides, so every existing call site through event_lines and replace_event_property is fixed at once rather than one at a time.

RECURRENCE-ID was unusable for the thing it is needed for

It was parsed for TZID and VALUE only and rendered to a UTC instant. An EXDATE has to carry the same value type and TZID the RRULE generates, so an exclusion built from our recurrence_id would be exactly the silently-ignored EXDATE the proposal in #16 warns about — we would have produced the bug the specification is written to avoid.

Event now carries, alongside the rendered form:

  • recurrence_id_value — the value as the server wrote it, 20260901T090000 or 20260901
  • recurrence_id_tzid — absent for UTC and floating values
  • recurrence_id_range — normally THISANDFUTURE

RANGE was dropped entirely before this. An override carrying it applies to that occurrence and every later one, and a caller that cannot see it treats a this-and-future override as a single-instance one. That is the silent-ignore #16 lists as a correctness rule, and it was live on the read path rather than hypothetical.

Verified by mutation

Four, each against the full suite of 107.

mutation died
master_event_start returns the first VEVENT patch_edits_the_master_when_an_override_is_written_first, alone
parse_ical_event takes the first component a_calendar_object_is_described_by_its_master_component, alone
recurrence_id_range hardcoded to None recurrence_id_is_surfaced_verbatim_with_its_parameters, alone
recurrence_id_value rendered instead of verbatim that test and an_override_without_range_reports_none

The first three kill exactly one test each, so each is load-bearing rather than covered by a neighbour. The fourth kills two because the all-day case renders differently from its verbatim value, which is the same claim from a second angle.

Gates

cargo fmt --check, cargo clippy -D warnings, cargo test --all-features (107 + 9), all green on rustc 1.98.0, run through the shared build slot.

Not in this change

delete_occurrence, update_occurrence, get_event_raw, and rejecting RANGE=THISANDFUTURE on the write paths — there is no write path that can accept it yet. Sequenced on #16.

Refs #16. A live defect found while assessing the recurrence proposal, plus the instance metadata that everything else in that sequence depends on. ## The defect `patch_ics` and `parse_ical_event` both anchored on the **first** `BEGIN:VEVENT`. A calendar object for a recurring series holds the master and one `VEVENT` per override, and RFC 5545 does not require the master first. Measured before it was fixed, with a scratch test against an override-first object: ```text PROBE parse_ical_event summary=Some("Override") rrule=None recurrence_id=Some("2026-09-01T01:00:00Z") PROBE patched SUMMARY:Renamed <- the override PROBE patched SUMMARY:Master <- untouched ``` So `update_event` on such a series renamed **one occurrence**, reported success, and returned `recurrence_rule: null` for a series that has one. A client reading that answer stops treating the event as recurring. `master_event_start` now selects the component with no `RECURRENCE-ID`, and it is the single place that decides, so every existing call site through `event_lines` and `replace_event_property` is fixed at once rather than one at a time. ## `RECURRENCE-ID` was unusable for the thing it is needed for It was parsed for `TZID` and `VALUE` only and rendered to a UTC instant. An `EXDATE` has to carry the same value type and `TZID` the `RRULE` generates, so an exclusion built from our `recurrence_id` would be exactly the silently-ignored `EXDATE` the proposal in #16 warns about — we would have produced the bug the specification is written to avoid. `Event` now carries, alongside the rendered form: - `recurrence_id_value` — the value as the server wrote it, `20260901T090000` or `20260901` - `recurrence_id_tzid` — absent for UTC and floating values - `recurrence_id_range` — normally `THISANDFUTURE` `RANGE` was dropped entirely before this. An override carrying it applies to that occurrence **and every later one**, and a caller that cannot see it treats a this-and-future override as a single-instance one. That is the silent-ignore #16 lists as a correctness rule, and it was live on the read path rather than hypothetical. ## Verified by mutation Four, each against the full suite of 107. | mutation | died | |---|---| | `master_event_start` returns the first `VEVENT` | `patch_edits_the_master_when_an_override_is_written_first`, alone | | `parse_ical_event` takes the first component | `a_calendar_object_is_described_by_its_master_component`, alone | | `recurrence_id_range` hardcoded to `None` | `recurrence_id_is_surfaced_verbatim_with_its_parameters`, alone | | `recurrence_id_value` rendered instead of verbatim | that test and `an_override_without_range_reports_none` | The first three kill exactly one test each, so each is load-bearing rather than covered by a neighbour. The fourth kills two because the all-day case renders differently from its verbatim value, which is the same claim from a second angle. ## Gates `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all-features` (107 + 9), all green on `rustc 1.98.0`, run through the shared build slot. ## Not in this change `delete_occurrence`, `update_occurrence`, `get_event_raw`, and rejecting `RANGE=THISANDFUTURE` on the write paths — there is no write path that can accept it yet. Sequenced on #16.
fix(caldav): patch the master component, and surface RECURRENCE-ID verbatim
All checks were successful
CI / cargo (pull_request) Successful in 37s
CI / docker (pull_request) Successful in 52s
0a99debc71
A calendar object for a recurring series holds the master VEVENT and one per
override, and RFC 5545 does not require the master first. `patch_ics` and
`parse_ical_event` both anchored on the first `BEGIN:VEVENT`, so where a server
wrote an override first, `update_event` renamed one occurrence while reporting
that it had renamed the series, and returned `recurrence_rule: null` for a
series that has one. Measured, not inferred: a patch setting SUMMARY landed on
the override and left the master untouched.

`master_event_start` now picks the component with no RECURRENCE-ID and is the
single place that decides, so every existing call site through `event_lines`
and `replace_event_property` is fixed at once.

Separately, `RECURRENCE-ID` was parsed only for TZID and VALUE and rendered as
a UTC instant, which cannot be used to build an EXDATE — an exclusion must
carry the same value type and TZID the RRULE generates. `Event` now also
carries `recurrence_id_value`, `recurrence_id_tzid` and `recurrence_id_range`
verbatim. RANGE was dropped entirely, so a `RANGE=THISANDFUTURE` override,
which applies to that occurrence and every later one, was presented as a
single-instance override.

Refs #16
jlxq0 merged commit 3dfc511fe8 into main 2026-08-26 09:04:25 +00:00
jlxq0 deleted branch fix-master-component 2026-08-26 09:04:25 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/caldav-mcp!17
No description provided.