Preservation-Safe iCalendar Patcher #14

Closed
opened 2026-09-22 17:58:55 +00:00 by yuris · 1 comment
Owner

Summary

Implement a semantic, preservation-oriented iCalendar patcher that updates requested properties while strictly retaining unknown properties, parameters, alarms (VALARM), custom timezone definitions (VTIMEZONE), multiline folded Unicode text, and unrelated overrides.

Background & Technical Rationale

A catastrophic failure mode in calendar integration software is the "clean-serializer round-trip" antipattern. In naive implementations, an incoming CalDAV object (e.g., an RFC 5545 VCALENDAR resource containing a VEVENT or VTODO) is parsed into a simplified internal domain struct (e.g. struct Task { title: String, due: Option<DateTime> }). When updating a property such as the title or status, the application re-serializes the struct through a clean generator.

In real-world environments (such as Apple Calendar, Google Calendar, Thunderbird, or Nextcloud), CalDAV objects contain critical metadata that the local domain model does not natively represent:

  • Vendor extension properties: X-APPLE-STRUCTURED-LOCATION, X-GOOGLE-CONFERENCE, X-MICROSOFT-CDO-*, X-MOZ-LASTACK.
  • Custom alarm components: BEGIN:VALARM ... END:VALARM with custom audio, display triggers, and snooze state.
  • Custom embedded timezones: BEGIN:VTIMEZONE ... END:VTIMEZONE defining local transition rules.
  • Property parameters: TZID=Custom-Zone, ROLE=REQ-PARTICIPANT, PARTSTAT=ACCEPTED, RSVP=TRUE.
  • Multiline folded text: RFC 5545 Section 3.1 line foldings across multibyte UTF-8 code point boundaries.
  • Detached override components: Sibling VEVENT / VTODO blocks representing rescheduled or cancelled occurrences of a recurring series sharing the same UID.

Deserializing through a clean builder strips all of this data, resulting in silent data loss, broken alarms, and destroyed calendar sync on user devices.

To eliminate this class of data corruption, Life establishes strict preservation invariants:

  1. Preservation-Oriented Patching: Component patching must operate on the parsed raw structure (AST / component tree using ical), modifying only the specific requested fields (such as SUMMARY, STATUS, DUE, DTSTART, CATEGORIES, or Life-specific X-LIFE-* tags).
  2. Strict Builder Prohibition: Never serialize an external object through the new-item builder.
  3. Fail-Safe Abort: If an external object cannot be safely updated without data loss or structural distortion, the mutation MUST be refused with calendar_unsupported. Silent dropping of unmodeled fields is strictly prohibited.
  4. Semantic Preservation: Compare normalized intent for crash recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity, but untouched properties and components must remain semantically identical after mutation.

Exact Technical References & Specifications

Preservation Contract

From docs/technical/calendar.md (lines 12–14):

"Store original unexpanded raw resource separately from normalized projection. Same-UID master and detached overrides share one resource; different UIDs/component kinds do not share an ordinary CalDAV object. Existing-object edits patch only requested properties in a preservation-oriented representation, retaining parameters, unknown properties, alarms, timezone definitions and unrelated overrides. Never serialize an external object through the new-item builder. If preservation cannot be guaranteed, refuse with calendar_unsupported. Preserve line folding/escaping semantically; compare normalized intent for recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity."

Conformance Gates

From docs/technical/calendar.md (lines 49–51):

"4. Full-resource preservation of unknown properties/parameters, alarms and overrides on conditional edits."

Verification Matrix Scenario C09

From docs/technical/verification.md (lines 43, 50):

ID Input / failure Observable acceptance
C09 Unknown property/parameter, VALARM, folded Unicode text and unrelated overrides during title edit Full-resource semantic preservation or mutation refused; never new-item builder round-trip

Illustrative Preservation Fixture

The following complex iCalendar object illustrates the preservation requirements:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Fictional Calendar Vendor//EN
BEGIN:VTIMEZONE
TZID:Custom-Timezone
BEGIN:STANDARD
DTSTART:19700101T000000
TZOFFSETFROM:+1000
TZOFFSETTO:+1000
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:fictional-complex-uid
DTSTAMP:20300401T000000Z
DTSTART;TZID=Custom-Timezone:20300408T090000
DTEND;TZID=Custom-Timezone:20300408T100000
SUMMARY:Original Focus Title
DESCRIPTION:This is a multiline description that has been folded according to
  RFC 5545 with Unicode characters: 🚀 Testing — preservation safe!
X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS="123 Fictional Way":geo:-27.4698,153.0251
X-CUSTOM-VENDOR-PROP;X-PARAM="preserved":true
STATUS:CONFIRMED
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-PT15M
END:VALARM
END:VEVENT
BEGIN:VEVENT
UID:fictional-complex-uid
RECURRENCE-ID;TZID=Custom-Timezone:20300410T090000
DTSTAMP:20300401T000000Z
DTSTART;TZID=Custom-Timezone:20300411T110000
DTEND;TZID=Custom-Timezone:20300411T120000
SUMMARY:Unrelated Override Title
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

Section Citations

  • docs/technical/calendar.md: Section Discovery and conditional writes (lines 12–14), Section Conformance gates (lines 49–51).
  • docs/technical/verification.md: Section Calendar scenarios (lines 43, 50, Scenario C09).
  • docs/technical/domain-model.md: Section Identity and relationships (lines 44–50).

What to Build

1. Preservation-Safe iCalendar Patcher (src/calendar/patcher.rs)

  • Implement ICalendarPatcher operating directly on the parsed AST of the raw resource:
    pub struct ICalendarPatcher<'a> {
        raw_ics: &'a str,
    }
    
    pub struct PropertyPatch {
        pub name: String,
        pub value: String,
        pub parameters: Option<Vec<(String, Vec<String>)>>,
    }
    
    pub struct ComponentPatch {
        pub target_uid: String,
        pub target_recurrence_id: Option<String>,
        pub property_patches: Vec<PropertyPatch>,
    }
    
  • Parse the raw ICS into an AST representation (leveraging ical::parser::ical::IcalParser or a dedicated lossless line/component AST) that preserves:
    • All top-level components (VTIMEZONE, VEVENT, VTODO, etc.).
    • All nested sub-components (VALARM).
    • All property parameters (TZID, VALUE, X-*).
    • All unrecognized properties (X-* and standard RFC 5545 properties not modeled in domain types).
  • Target resolution:
    • Locate the exact component within the VCALENDAR matching UID and optional RECURRENCE-ID.
    • Leave all non-targeted components (including unrelated detached overrides and timezone definitions) completely untouched.
  • Surgical property update:
    • If the property exists on the target component, update its value. Preserve existing parameters unless the patch explicitly mutates or replaces them.
    • If the property does not exist, append it to the target component.
    • If a property is cleared (e.g. removing a deadline), remove only that property from the target component.
  • RFC 5545 Section 3.1 Line Folding:
    • Format serialized lines to not exceed 75 octets per line.
    • Fold lines cleanly with CRLF (\r\n) followed by a single space or horizontal tab.
    • Ensure multibyte UTF-8 characters are never split mid-codepoint during folding.
  • Preservation Verification & Semantic Diff:
    • Before returning the patched ICS bytes, perform an internal AST verification comparing the original and patched objects:
      • Assert all VALARM components are identical.
      • Assert all VTIMEZONE components are identical.
      • Assert all non-targeted properties on the target component are identical in name, parameter map, and value.
      • Assert all non-targeted sibling components are identical.
    • If the verification fails, abort the operation and return CalendarError::Unsupported("calendar_unsupported").

2. Integration with Calendar Mutation Pipeline

  • When life task update or life event update is invoked:
    • Retrieve the current raw resource from storage or remote CalDAV GET.
    • Run the ICalendarPatcher against the raw bytes.
    • Compute the intended normalized representation and verify intent.
    • Send the conditionally patched raw bytes to Radicale via HTTP PUT with If-Match: <etag>.
    • Never route through the icalendar builder used for new-item creation.

3. Conformance Test Suite (tests/calendar_patcher_tests.rs)

  • Load the illustrative complex fixture containing VTIMEZONE, VALARM, custom X-* properties, multiline folded UTF-8 description, and a detached override.
  • Test 1: Mutate SUMMARY of the master event to "Updated Focus Title".
    • Assert that SUMMARY is updated.
    • Assert that VALARM is present and unmodified.
    • Assert that VTIMEZONE is present and unmodified.
    • Assert that X-APPLE-STRUCTURED-LOCATION and X-CUSTOM-VENDOR-PROP are present with all parameters intact.
    • Assert that the folded description with emoji (🚀) and em-dash (—) retains valid UTF-8 encoding and meaning.
    • Assert that the detached override for 20300410T090000 remains completely intact.
  • Test 2: Structural corruption detection.
    • Provide an invalid or unparseable raw ICS stream.
    • Assert that the patcher returns CalendarError::Unsupported("calendar_unsupported") rather than generating a corrupted partial resource.

Acceptance Criteria

  • Mutating a property (e.g. SUMMARY or STATUS) on a complex CalDAV object preserves all unknown vendor properties (X-*), parameters, VALARM components, and VTIMEZONE blocks unchanged (Scenario C09).
  • Multiline folded descriptions and Unicode strings (including multibyte emojis) preserve encoding, line folding rules, and semantic content without corruption.
  • Modifying a master event does not alter or drop sibling detached overrides sharing the same UID.
  • Modifying an existing item NEVER passes through the new-item builder or drops unmodeled fields.
  • Any failure to guarantee lossless preservation aborts the mutation with calendar_unsupported (HTTP 422 / Exit code 2 or 5 depending on protocol mapping).
  • Verifies Conformance Gate 4 from calendar.md and Scenario C09 from verification.md.

Notes

  • The patcher landed with #11 (src/calendar/preservation.rs, a lossless line/component AST per this issue's allowance for "a dedicated lossless line/component AST"); PR #52 supplied the conformance evidence and reconciled calendar_unsupported to HTTP 422 / exit 2.
  • Deferred Cargo.toml authors chore still pending (no dependency added).
  • Finding 2 (custom non-IANA TZID read-path classification) tracked separately.

Blocked by

  • #013: Recurrence Expansion Parser & Conformance Fixtures
## Summary Implement a semantic, preservation-oriented iCalendar patcher that updates requested properties while strictly retaining unknown properties, parameters, alarms (`VALARM`), custom timezone definitions (`VTIMEZONE`), multiline folded Unicode text, and unrelated overrides. ## Background & Technical Rationale A catastrophic failure mode in calendar integration software is the "clean-serializer round-trip" antipattern. In naive implementations, an incoming CalDAV object (e.g., an RFC 5545 `VCALENDAR` resource containing a `VEVENT` or `VTODO`) is parsed into a simplified internal domain struct (e.g. `struct Task { title: String, due: Option<DateTime> }`). When updating a property such as the title or status, the application re-serializes the struct through a clean generator. In real-world environments (such as Apple Calendar, Google Calendar, Thunderbird, or Nextcloud), CalDAV objects contain critical metadata that the local domain model does not natively represent: - Vendor extension properties: `X-APPLE-STRUCTURED-LOCATION`, `X-GOOGLE-CONFERENCE`, `X-MICROSOFT-CDO-*`, `X-MOZ-LASTACK`. - Custom alarm components: `BEGIN:VALARM ... END:VALARM` with custom audio, display triggers, and snooze state. - Custom embedded timezones: `BEGIN:VTIMEZONE ... END:VTIMEZONE` defining local transition rules. - Property parameters: `TZID=Custom-Zone`, `ROLE=REQ-PARTICIPANT`, `PARTSTAT=ACCEPTED`, `RSVP=TRUE`. - Multiline folded text: RFC 5545 Section 3.1 line foldings across multibyte UTF-8 code point boundaries. - Detached override components: Sibling `VEVENT` / `VTODO` blocks representing rescheduled or cancelled occurrences of a recurring series sharing the same UID. Deserializing through a clean builder strips all of this data, resulting in silent data loss, broken alarms, and destroyed calendar sync on user devices. To eliminate this class of data corruption, Life establishes strict preservation invariants: 1. **Preservation-Oriented Patching:** Component patching must operate on the parsed raw structure (AST / component tree using `ical`), modifying *only* the specific requested fields (such as `SUMMARY`, `STATUS`, `DUE`, `DTSTART`, `CATEGORIES`, or Life-specific `X-LIFE-*` tags). 2. **Strict Builder Prohibition:** Never serialize an external object through the new-item builder. 3. **Fail-Safe Abort:** If an external object cannot be safely updated without data loss or structural distortion, the mutation MUST be refused with `calendar_unsupported`. Silent dropping of unmodeled fields is strictly prohibited. 4. **Semantic Preservation:** Compare normalized intent for crash recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity, but untouched properties and components must remain semantically identical after mutation. ## Exact Technical References & Specifications ### Preservation Contract From `docs/technical/calendar.md` (lines 12–14): > "Store original unexpanded raw resource separately from normalized projection. Same-UID master and detached overrides share one resource; different UIDs/component kinds do not share an ordinary CalDAV object. Existing-object edits patch only requested properties in a preservation-oriented representation, retaining parameters, unknown properties, alarms, timezone definitions and unrelated overrides. Never serialize an external object through the new-item builder. If preservation cannot be guaranteed, refuse with calendar_unsupported. Preserve line folding/escaping semantically; compare normalized intent for recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity." ### Conformance Gates From `docs/technical/calendar.md` (lines 49–51): > "4. Full-resource preservation of unknown properties/parameters, alarms and overrides on conditional edits." ### Verification Matrix Scenario C09 From `docs/technical/verification.md` (lines 43, 50): | ID | Input / failure | Observable acceptance | |---|---|---| | C09 | Unknown property/parameter, VALARM, folded Unicode text and unrelated overrides during title edit | Full-resource semantic preservation or mutation refused; never new-item builder round-trip | ### Illustrative Preservation Fixture The following complex iCalendar object illustrates the preservation requirements: ```ics BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Fictional Calendar Vendor//EN BEGIN:VTIMEZONE TZID:Custom-Timezone BEGIN:STANDARD DTSTART:19700101T000000 TZOFFSETFROM:+1000 TZOFFSETTO:+1000 END:STANDARD END:VTIMEZONE BEGIN:VEVENT UID:fictional-complex-uid DTSTAMP:20300401T000000Z DTSTART;TZID=Custom-Timezone:20300408T090000 DTEND;TZID=Custom-Timezone:20300408T100000 SUMMARY:Original Focus Title DESCRIPTION:This is a multiline description that has been folded according to RFC 5545 with Unicode characters: 🚀 Testing — preservation safe! X-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS="123 Fictional Way":geo:-27.4698,153.0251 X-CUSTOM-VENDOR-PROP;X-PARAM="preserved":true STATUS:CONFIRMED BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:Reminder TRIGGER:-PT15M END:VALARM END:VEVENT BEGIN:VEVENT UID:fictional-complex-uid RECURRENCE-ID;TZID=Custom-Timezone:20300410T090000 DTSTAMP:20300401T000000Z DTSTART;TZID=Custom-Timezone:20300411T110000 DTEND;TZID=Custom-Timezone:20300411T120000 SUMMARY:Unrelated Override Title STATUS:CONFIRMED END:VEVENT END:VCALENDAR ``` ### Section Citations - `docs/technical/calendar.md`: Section *Discovery and conditional writes* (lines 12–14), Section *Conformance gates* (lines 49–51). - `docs/technical/verification.md`: Section *Calendar scenarios* (lines 43, 50, Scenario C09). - `docs/technical/domain-model.md`: Section *Identity and relationships* (lines 44–50). ## What to Build ### 1. Preservation-Safe iCalendar Patcher (`src/calendar/patcher.rs`) - Implement `ICalendarPatcher` operating directly on the parsed AST of the raw resource: ```rust pub struct ICalendarPatcher<'a> { raw_ics: &'a str, } pub struct PropertyPatch { pub name: String, pub value: String, pub parameters: Option<Vec<(String, Vec<String>)>>, } pub struct ComponentPatch { pub target_uid: String, pub target_recurrence_id: Option<String>, pub property_patches: Vec<PropertyPatch>, } ``` - Parse the raw ICS into an AST representation (leveraging `ical::parser::ical::IcalParser` or a dedicated lossless line/component AST) that preserves: - All top-level components (`VTIMEZONE`, `VEVENT`, `VTODO`, etc.). - All nested sub-components (`VALARM`). - All property parameters (`TZID`, `VALUE`, `X-*`). - All unrecognized properties (`X-*` and standard RFC 5545 properties not modeled in domain types). - Target resolution: - Locate the exact component within the `VCALENDAR` matching `UID` and optional `RECURRENCE-ID`. - Leave all non-targeted components (including unrelated detached overrides and timezone definitions) completely untouched. - Surgical property update: - If the property exists on the target component, update its value. Preserve existing parameters unless the patch explicitly mutates or replaces them. - If the property does not exist, append it to the target component. - If a property is cleared (e.g. removing a deadline), remove only that property from the target component. - RFC 5545 Section 3.1 Line Folding: - Format serialized lines to not exceed 75 octets per line. - Fold lines cleanly with CRLF (`\r\n`) followed by a single space or horizontal tab. - Ensure multibyte UTF-8 characters are never split mid-codepoint during folding. - Preservation Verification & Semantic Diff: - Before returning the patched ICS bytes, perform an internal AST verification comparing the original and patched objects: - Assert all `VALARM` components are identical. - Assert all `VTIMEZONE` components are identical. - Assert all non-targeted properties on the target component are identical in name, parameter map, and value. - Assert all non-targeted sibling components are identical. - If the verification fails, abort the operation and return `CalendarError::Unsupported("calendar_unsupported")`. ### 2. Integration with Calendar Mutation Pipeline - When `life task update` or `life event update` is invoked: - Retrieve the current raw resource from storage or remote CalDAV GET. - Run the `ICalendarPatcher` against the raw bytes. - Compute the intended normalized representation and verify intent. - Send the conditionally patched raw bytes to Radicale via HTTP `PUT` with `If-Match: <etag>`. - Never route through the `icalendar` builder used for new-item creation. ### 3. Conformance Test Suite (`tests/calendar_patcher_tests.rs`) - Load the illustrative complex fixture containing `VTIMEZONE`, `VALARM`, custom `X-*` properties, multiline folded UTF-8 description, and a detached override. - Test 1: Mutate `SUMMARY` of the master event to `"Updated Focus Title"`. - Assert that `SUMMARY` is updated. - Assert that `VALARM` is present and unmodified. - Assert that `VTIMEZONE` is present and unmodified. - Assert that `X-APPLE-STRUCTURED-LOCATION` and `X-CUSTOM-VENDOR-PROP` are present with all parameters intact. - Assert that the folded description with emoji (`🚀`) and em-dash (`—`) retains valid UTF-8 encoding and meaning. - Assert that the detached override for `20300410T090000` remains completely intact. - Test 2: Structural corruption detection. - Provide an invalid or unparseable raw ICS stream. - Assert that the patcher returns `CalendarError::Unsupported("calendar_unsupported")` rather than generating a corrupted partial resource. ## Acceptance Criteria - [x] Mutating a property (e.g. `SUMMARY` or `STATUS`) on a complex CalDAV object preserves all unknown vendor properties (`X-*`), parameters, `VALARM` components, and `VTIMEZONE` blocks unchanged (Scenario C09). - [x] Multiline folded descriptions and Unicode strings (including multibyte emojis) preserve encoding, line folding rules, and semantic content without corruption. - [x] Modifying a master event does not alter or drop sibling detached overrides sharing the same `UID`. - [x] Modifying an existing item NEVER passes through the new-item builder or drops unmodeled fields. - [x] Any failure to guarantee lossless preservation aborts the mutation with `calendar_unsupported` (HTTP 422 / Exit code 2 or 5 depending on protocol mapping). - [x] Verifies Conformance Gate 4 from `calendar.md` and Scenario C09 from `verification.md`. ## Notes - The patcher landed with #11 (`src/calendar/preservation.rs`, a lossless line/component AST per this issue's allowance for "a dedicated lossless line/component AST"); PR #52 supplied the conformance evidence and reconciled `calendar_unsupported` to HTTP 422 / exit 2. - Deferred `Cargo.toml` authors chore still pending (no dependency added). - Finding 2 (custom non-IANA `TZID` read-path classification) tracked separately. ## Blocked by - #013: Recurrence Expansion Parser & Conformance Fixtures
yuris changed title from Issue #014: Preservation-Safe iCalendar Patcher to Preservation-Safe iCalendar Patcher 2026-09-22 20:02:38 +00:00
Author
Owner

Deferred chore: Cargo.toml authors line

Cargo.toml currently reads authors = ["Yuri Sasc <yuri@nokopia.com>"]. It should be authors = ["Yuris Cakranegara <hello@yuris.dev>"].

The src/cli.rs author (which drives life --help/--version) was already corrected in #11. The Cargo.toml line was deliberately not changed in #11 because Cargo.toml is otherwise untouched by that slice, and an unrelated metadata line does not belong in its diff.

Action: if this issue ends up editing Cargo.toml (e.g. adding a parser dependency for the preservation AST), include the authors line in the same commit.

Fallback: this issue may not need a Cargo.toml change — its spec explicitly allows "a dedicated lossless line/component AST" instead of ical::parser::ical::IcalParser, and #11 already built exactly that. If no dependency is added here, carry the chore to the next issue that genuinely edits Cargo.toml; #35 is the most likely (constant-time digest comparison may replace the deprecated ring::constant_time::verify_slices_are_equal with subtle).

One line, no behaviour change. Do not open a standalone PR for it.

## Deferred chore: `Cargo.toml` authors line `Cargo.toml` currently reads `authors = ["Yuri Sasc <yuri@nokopia.com>"]`. It should be `authors = ["Yuris Cakranegara <hello@yuris.dev>"]`. The `src/cli.rs` author (which drives `life --help`/`--version`) was already corrected in #11. The `Cargo.toml` line was deliberately **not** changed in #11 because `Cargo.toml` is otherwise untouched by that slice, and an unrelated metadata line does not belong in its diff. **Action:** if this issue ends up editing `Cargo.toml` (e.g. adding a parser dependency for the preservation AST), include the authors line in the same commit. **Fallback:** this issue may not need a `Cargo.toml` change — its spec explicitly allows "a dedicated lossless line/component AST" instead of `ical::parser::ical::IcalParser`, and #11 already built exactly that. If no dependency is added here, carry the chore to the next issue that genuinely edits `Cargo.toml`; #35 is the most likely (constant-time digest comparison may replace the deprecated `ring::constant_time::verify_slices_are_equal` with `subtle`). One line, no behaviour change. Do not open a standalone PR for it.
yuris closed this issue 2026-09-22 22:37:52 +00:00
Sign in to join this conversation.
No labels
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
yuris/life-service#14
No description provided.