Bounded CalDAV REPORT Calendar-Query & Timezone Expansion #8

Closed
opened 2026-09-22 17:58:54 +00:00 by yuris · 0 comments
Owner

Summary

Implement bounded CalDAV REPORT calendar-query queries against Radicale, performing server-side recurrence expansion via <C:expand>, strict half-open interval [from, to) calculation in a mandatory configured IANA timezone, tagged date/instant representations, and strict query limit enforcement (maximum 93 days, maximum 10,000 expanded instances) with fail-fast error semantics.


Background & Technical Rationale

  1. Server-Side Recurrence Expansion as the Primary Engine:
    Expanding recurring iCalendar events (RRULE, RDATE, EXDATE, detached overrides) client-side is notoriously error-prone, bug-ridden, and risks subtle divergence from standard calendar clients. As specified in docs/technical/calendar.md, life-service does not implement a speculative local recurrence expansion engine. Instead, bounded server-side expansion via CalDAV RFC 4791 REPORT with <C:expand start="..." end="..."/> is the authoritative recurrence engine.

  2. Mandatory IANA Timezone & Half-Open Temporal Intervals:

    • No Host Timezone Fallback: A configured IANA timezone (e.g. Australia/Brisbane, America/New_York) is mandatory. If the timezone is missing or invalid, configuration validation halts startup. Falling back to the host machine's timezone, IP geolocation, or fixed UTC offsets is strictly forbidden.
    • Local Calendar Calculation to UTC: Query boundaries for periods (today, tomorrow, week) must be calculated in the configured local calendar time first, then converted to UTC instants. For example, week starts at Monday 00:00:00 local time and ends at the following Monday 00:00:00 local time. Because of Daylight Saving Time (DST) transitions, a local calendar day or week may not contain 24 hours or 168 hours.
    • Half-Open Intervals [from, to): All time ranges are half-open: inclusive of the start instant, exclusive of the end instant. A human expression like "through Friday" resolves to Saturday at 00:00:00 local midnight.
    • All-Day Event Handling: All-day events use iCalendar DATE types (DTSTART:20300408, DTEND:20300409). In accordance with RFC 5545, DTEND is exclusive: an event spanning April 8 starts at 20300408 and ends at 20300409. It occupies April 8 only.
  3. Strict Query Limits & Denial-of-Service Defense:
    Infinite recurrence series and unbounded queries can easily overwhelm the server memory and network bandwidth.

    • Span Ceiling: Maximum query span is 93 days (~3 calendar months).
    • Instance Ceiling: Maximum expanded instances returned across a query is 10,000 instances.
    • Fail-Fast Semantics: If a query exceeds either limit, the service MUST return limit_exceeded (HTTP 422 / CLI exit code 2) with instructions to query a narrower window. It must never silently truncate results.
  4. Ambiguity and DST Transitions:

    • DST Fall-Back (Fold): During a DST repeat hour (e.g., 01:30 occurring twice), local inputs require an explicit UTC offset or occurrence choice.
    • DST Spring-Forward (Gap): Nonexistent local times (e.g., 02:30 skipped) reject automatic shifting and require explicit client clarification.
  5. Dependency Unavailability vs Empty State (Scenario C01):
    A failed network call or unreachable Radicale server MUST return dependency_unavailable (HTTP 503 / CLI exit code 5). Memory or cached text is never a fallback authority: an unavailable calendar must never be reported as an empty agenda.


Exact Technical References & Specifications

Normative Quotations from Technical Architecture

From docs/technical/calendar.md (Query and time semantics, lines 15–28):

"- Require a configured IANA timezone. Missing timezone fails configuration; never use the host zone, remembered location or guessed fixed offset.

  • Internally use half-open [from,to) ranges. Human “through Friday” resolves to the following local midnight. Day/week boundaries are constructed in local calendar time, then converted to UTC; a day need not contain 24 hours. Week starts Monday.
  • Default maximum query span is 93 days, maximum 10,000 expanded instances. Exceeding either returns limit_exceeded with a smaller-range instruction; never silently truncate. Finite bounds apply even to infinite series. Paginated results obey the API contract.
  • All-day DTSTART/DTEND use DATE; DTEND is exclusive. A one-day all-day event ends on the next local date. Missing DTEND follows RFC defaults, not a fabricated full-day timed interval.
  • Floating DATE-TIME is interpreted in configured calendar timezone and disclosed as floating-origin data. Preserve that representation when editing unrelated fields. UTC instants, TZID dates and date-only values remain distinct.
  • Ambiguous DST local times (fold) require an explicit offset/occurrence choice consistent with the zone. Nonexistent local times (gap) require clarification, never automatic shifting. This strict input policy does not rewrite an existing external object's RFC-defined recurrence interpretation.
  • Honor TZID and embedded VTIMEZONE. Unknown/custom zones must be supported by expansion with verified mapping or reported unsupported; chrono-tz cannot be assumed to represent arbitrary VTIMEZONE definitions.
  • VTODO DUE is a deadline, DTSTART planned earliest work; neither alone reserves busy time. Undated tasks remain in tasks list and planning candidates. Date-only deadline urgency uses the end of that local date while preserving its date representation. Do not invent midnight appointments.
  • Transparent VEVENTs appear in agenda but do not consume busy capacity. Cancelled occurrences do not consume capacity. Opaque tentative events conservatively count busy unless policy explicitly says otherwise. Missing or unsupported semantics prevent declaring the affected window free.

Schedule answers always perform current backend reads, return as_of/per-collection observation times and completeness. CalDAV has no global snapshot: concurrent changes can produce an explicitly incomplete observation despite successful individual requests. Memory is never fallback authority; a failed calendar read is not an empty agenda."

From docs/technical/calendar.md (Recurrence and mutation, lines 29–36):

"Expansion must honor RRULE, RDATE, EXDATE, cancellations and detached overrides. Occurrence identity is collection+UID+original RECURRENCE-ID, including value type/timezone semantics; a moved occurrence's actual DTSTART differs without changing its identity. Retrieval must find overrides moved into the query range from outside and exclude moved-out/cancelled occurrences without duplication. Keep the raw master/override set for all writes, even when display uses expanded instances.

Request bounded calendar-data expansion through REPORT and normalize its results. The adapter must validate returned bounds and expansion completeness, not assume support because the request succeeded. Required cases include RANGE=THISANDFUTURE. If the server cannot expand a required case, the deployment is incompatible with complete agenda/planning until corrected: calendar_unsupported or explicitly partial agenda, never master-only output presented as complete."

From docs/technical/cli-api.md (Error identifiers, lines 118–120):

Code Meaning / handling
dependency_unavailable Named backend unreachable/unhealthy; do not infer empty state
calendar_unsupported Cannot interpret/preserve required semantics
limit_exceeded, cursor_expired Narrow range or restart paginated query

From docs/technical/verification.md (Calendar scenarios, lines 33–42):

ID Input / failure Observable acceptance
C01 Complete empty agenda versus unreachable Radicale Empty complete success versus dependency_unavailable; never the same response
C03 America/New_York 2030-11-03 01:30 and 2030-03-10 02:30 as new local inputs Fold requires offset/choice; gap requires clarification, no automatic shift
C04 All-day DTSTART 20300408 / DTEND 20300409 Occupies exactly April 8 in configured zone; exclusive boundary
C08 Infinite recurrence, >93-day query or >10,000 instances Explicit limit_exceeded and smaller-range instruction, no silent truncation

Protocol Specifications & XML Envelopes

1. Bounded REPORT calendar-query Request Envelope (RFC 4791)

REPORT /user/calendars/personal/ HTTP/1.1
Host: radicale.example.internal
Depth: 1
Prefer: return=minimal
Content-Type: application/xml; charset=utf-8
Authorization: Basic <base64(user:pass)>

<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop>
    <D:getetag/>
    <C:calendar-data>
      <C:expand start="20300408T000000Z" end="20300415T000000Z"/>
    </C:calendar-data>
  </D:prop>
  <C:filter>
    <C:comp-filter name="VCALENDAR">
      <C:comp-filter name="VEVENT">
        <C:time-range start="20300408T000000Z" end="20300415T000000Z"/>
      </C:comp-filter>
    </C:comp-filter>
  </C:filter>
</C:calendar-query>

2. Expected 207 Multi-Status with Expanded ICS Data

<?xml version="1.0" encoding="utf-8" ?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:response>
    <D:href>/user/calendars/personal/event-123.ics</D:href>
    <D:propstat>
      <D:prop>
        <D:getetag>"etag-v1"</D:getetag>
        <C:calendar-data>BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Radicale//NONSGML Radicale Server//EN
BEGIN:VEVENT
UID:event-123
DTSTART:20300408T090000Z
DTEND:20300408T100000Z
SUMMARY:Architecture sync
STATUS:CONFIRMED
TRANSP:OPAQUE
END:VEVENT
END:VCALENDAR</C:calendar-data>
      </D:prop>
      <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
  </D:response>
</D:multistatus>

3. Error JSON Response: limit_exceeded

{
  "schema_version": 1,
  "request_id": "req_query_overflow",
  "status": "error",
  "data": null,
  "warnings": [],
  "error": {
    "code": "limit_exceeded",
    "message": "Requested query span 120 days exceeds maximum supported limit of 93 days; narrow query range",
    "retryable": false
  }
}

4. Error JSON Response: dependency_unavailable (Scenario C01)

{
  "schema_version": 1,
  "request_id": "req_demo_unavailable",
  "status": "error",
  "data": {
    "as_of": "2030-04-08T06:20:00Z",
    "complete": false,
    "coverage": [
      {
        "collection_id": "personal",
        "state": "unavailable"
      }
    ]
  },
  "warnings": [],
  "error": {
    "code": "dependency_unavailable",
    "message": "Calendar backend unreachable; connection refused",
    "retryable": true
  }
}

What to Build (Step-by-Step)

1. Timezone & Range Calculation (src/calendar/timezone.rs)

  • Model CalendarTimezone:
    • Wraps chrono_tz::Tz, parsed strictly from [radicale.timezone].
    • Provide resolution functions for standard named query periods:
      • period_today(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval: Local midnight today to local midnight tomorrow.
      • period_tomorrow(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval: Local midnight tomorrow to local midnight day after tomorrow.
      • period_week(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval: Local Monday 00:00:00 to following Monday 00:00:00.
    • Implement custom from_to(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<HalfOpenInterval, TimeError>:
      • Verify start < end.
      • Check duration: if (end - start) > 93 days, return TimeError::LimitExceeded("Query range exceeds 93 days").
  • Tagged Temporal Structs:
    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    #[serde(tag = "kind", content = "value")]
    pub enum TaggedTime {
        #[serde(rename = "date")]
        Date(NaiveDate),
        #[serde(rename = "instant")]
        Instant(DateTime<Utc>),
    }
    
  • DST Handling:
    • Implement fold/gap verification for user-supplied local datetimes using chrono::TimeZone::from_local_datetime.
    • If ambiguous (fold), require explicit offset; if nonexistent (gap), reject with invalid_input explaining that the local time does not exist due to DST transition.

2. CalDAV REPORT XML Builder (src/calendar/report.rs)

  • Function build_calendar_query_xml(interval: &HalfOpenInterval, component_types: &[ComponentKind]) -> String:
    • Construct XML with <C:calendar-query> requesting <D:getetag/> and <C:calendar-data>.
    • Include <C:expand start="YYYYMMDDTHHMMSSZ" end="YYYYMMDDTHHMMSSZ"/>.
    • Filter on <C:comp-filter name="VCALENDAR"> with child <C:comp-filter name="VEVENT"> and/or VTODO containing <C:time-range start="..." end="..."/>.
  • Dispatch request via CalDavClient:
    • Send HTTP REPORT to collection URL with header Depth: 1 and Content-Type: application/xml; charset=utf-8.
    • If server returns HTTP 5xx or connection error, map to CalDavError::DependencyUnavailable.

3. Response Parsing & Instance Count Enforcement (src/calendar/expansion.rs)

  • Parse the 207 Multi-Status response using quick-xml:
    • Extract each <D:response>, identifying the resource href, D:getetag, and <C:calendar-data>.
    • Parse raw calendar data using ical::PropertyParser into individual expanded VEVENT and VTODO components.
  • Count total expanded instances:
    • If instance count > 10,000, abort parsing and immediately return CalDavError::LimitExceeded("Expanded instance count exceeded 10,000").
  • Normalize component time properties:
    • For all-day events (VALUE=DATE): parse DTSTART and DTEND as NaiveDate. Ensure DTEND is treated as exclusive.
    • For UTC/TZID datetimes: parse into DateTime<Utc>.
    • For floating datetimes: interpret in configured CalendarTimezone.
    • Preserve RECURRENCE-ID if present, including its timezone identity.

Acceptance Criteria

  • Mandatory Timezone: Missing or unparseable IANA timezone string in [radicale] fails startup with configuration_invalid; the system never falls back to local machine timezone.
  • Period Boundary Calculations (Scenario W03): today, tomorrow, and week resolve to exact local-midnight-to-local-midnight half-open intervals in the configured timezone, converted accurately to UTC.
  • All-Day Event Exclusivity (Scenario C04): An all-day event with DTSTART:20300408 and DTEND:20300409 is normalized such that it occupies strictly April 8, ending at local midnight on April 9.
  • DST Fold & Gap Behavior (Scenario C03):
    • Parsing a local datetime falling in a DST fall-back fold without an explicit offset returns clarification_required / invalid_input.
    • Parsing a local datetime falling in a DST spring-forward gap is rejected rather than automatically shifted.
  • Span Ceiling Enforcement (Scenario C08): Requesting a query range greater than 93 days immediately returns limit_exceeded (HTTP 422, exit code 2) with instructions to query a narrower range.
  • Instance Ceiling Enforcement (Scenario C08): If an infinite recurrence series or dense calendar generates > 10,000 expanded instances in the server response, the parser aborts and returns limit_exceeded.
  • Backend Outage vs Empty Calendar (Scenario C01): When Radicale is stopped or connection drops, the service returns dependency_unavailable (HTTP 503, exit code 5); it never returns an empty agenda (items: []).
  • Server-Side Expansion: CalDAV REPORT XML payload contains <C:expand start="..." end="..."/> and correctly normalizes expanded occurrences returned by Radicale.

Blocked by

  • #007: CalDAV Connection Pooling, PROPFIND Discovery & Capabilities
## Summary Implement bounded CalDAV `REPORT` `calendar-query` queries against Radicale, performing server-side recurrence expansion via `<C:expand>`, strict half-open interval `[from, to)` calculation in a mandatory configured IANA timezone, tagged date/instant representations, and strict query limit enforcement (maximum 93 days, maximum 10,000 expanded instances) with fail-fast error semantics. --- ## Background & Technical Rationale 1. **Server-Side Recurrence Expansion as the Primary Engine:** Expanding recurring iCalendar events (`RRULE`, `RDATE`, `EXDATE`, detached overrides) client-side is notoriously error-prone, bug-ridden, and risks subtle divergence from standard calendar clients. As specified in `docs/technical/calendar.md`, `life-service` does not implement a speculative local recurrence expansion engine. Instead, bounded server-side expansion via CalDAV RFC 4791 `REPORT` with `<C:expand start="..." end="..."/>` is the authoritative recurrence engine. 2. **Mandatory IANA Timezone & Half-Open Temporal Intervals:** - **No Host Timezone Fallback:** A configured IANA timezone (e.g. `Australia/Brisbane`, `America/New_York`) is mandatory. If the timezone is missing or invalid, configuration validation halts startup. Falling back to the host machine's timezone, IP geolocation, or fixed UTC offsets is strictly forbidden. - **Local Calendar Calculation to UTC:** Query boundaries for periods (`today`, `tomorrow`, `week`) must be calculated in the configured local calendar time first, then converted to UTC instants. For example, `week` starts at Monday 00:00:00 local time and ends at the following Monday 00:00:00 local time. Because of Daylight Saving Time (DST) transitions, a local calendar day or week may not contain 24 hours or 168 hours. - **Half-Open Intervals `[from, to)`:** All time ranges are half-open: inclusive of the start instant, exclusive of the end instant. A human expression like "through Friday" resolves to Saturday at 00:00:00 local midnight. - **All-Day Event Handling:** All-day events use iCalendar `DATE` types (`DTSTART:20300408`, `DTEND:20300409`). In accordance with RFC 5545, `DTEND` is exclusive: an event spanning April 8 starts at `20300408` and ends at `20300409`. It occupies April 8 only. 3. **Strict Query Limits & Denial-of-Service Defense:** Infinite recurrence series and unbounded queries can easily overwhelm the server memory and network bandwidth. - **Span Ceiling:** Maximum query span is 93 days (~3 calendar months). - **Instance Ceiling:** Maximum expanded instances returned across a query is 10,000 instances. - **Fail-Fast Semantics:** If a query exceeds either limit, the service MUST return `limit_exceeded` (HTTP 422 / CLI exit code 2) with instructions to query a narrower window. It must **never** silently truncate results. 4. **Ambiguity and DST Transitions:** - **DST Fall-Back (Fold):** During a DST repeat hour (e.g., 01:30 occurring twice), local inputs require an explicit UTC offset or occurrence choice. - **DST Spring-Forward (Gap):** Nonexistent local times (e.g., 02:30 skipped) reject automatic shifting and require explicit client clarification. 5. **Dependency Unavailability vs Empty State (Scenario C01):** A failed network call or unreachable Radicale server MUST return `dependency_unavailable` (HTTP 503 / CLI exit code 5). Memory or cached text is never a fallback authority: an unavailable calendar must never be reported as an empty agenda. --- ## Exact Technical References & Specifications ### Normative Quotations from Technical Architecture From `docs/technical/calendar.md` (*Query and time semantics*, lines 15–28): > "- Require a configured IANA timezone. Missing timezone fails configuration; never use the host zone, remembered location or guessed fixed offset. > - Internally use half-open `[from,to)` ranges. Human “through Friday” resolves to the following local midnight. Day/week boundaries are constructed in local calendar time, then converted to UTC; a day need not contain 24 hours. Week starts Monday. > - Default maximum query span is 93 days, maximum 10,000 expanded instances. Exceeding either returns limit_exceeded with a smaller-range instruction; never silently truncate. Finite bounds apply even to infinite series. Paginated results obey the [API contract](cli-api.md#common-data-types-and-pagination). > - All-day DTSTART/DTEND use DATE; DTEND is exclusive. A one-day all-day event ends on the next local date. Missing DTEND follows RFC defaults, not a fabricated full-day timed interval. > - Floating DATE-TIME is interpreted in configured calendar timezone and disclosed as floating-origin data. Preserve that representation when editing unrelated fields. UTC instants, TZID dates and date-only values remain distinct. > - Ambiguous DST local times (fold) require an explicit offset/occurrence choice consistent with the zone. Nonexistent local times (gap) require clarification, never automatic shifting. This strict input policy does not rewrite an existing external object's RFC-defined recurrence interpretation. > - Honor TZID and embedded VTIMEZONE. Unknown/custom zones must be supported by expansion with verified mapping or reported unsupported; chrono-tz cannot be assumed to represent arbitrary VTIMEZONE definitions. > - VTODO DUE is a deadline, DTSTART planned earliest work; neither alone reserves busy time. Undated tasks remain in tasks list and planning candidates. Date-only deadline urgency uses the end of that local date while preserving its date representation. Do not invent midnight appointments. > - Transparent VEVENTs appear in agenda but do not consume busy capacity. Cancelled occurrences do not consume capacity. Opaque tentative events conservatively count busy unless policy explicitly says otherwise. Missing or unsupported semantics prevent declaring the affected window free. > > Schedule answers always perform current backend reads, return as_of/per-collection observation times and completeness. CalDAV has no global snapshot: concurrent changes can produce an explicitly incomplete observation despite successful individual requests. Memory is never fallback authority; a failed calendar read is not an empty agenda." From `docs/technical/calendar.md` (*Recurrence and mutation*, lines 29–36): > "Expansion must honor RRULE, RDATE, EXDATE, cancellations and detached overrides. Occurrence identity is collection+UID+original RECURRENCE-ID, including value type/timezone semantics; a moved occurrence's actual DTSTART differs without changing its identity. Retrieval must find overrides moved into the query range from outside and exclude moved-out/cancelled occurrences without duplication. Keep the raw master/override set for all writes, even when display uses expanded instances. > > Request bounded calendar-data expansion through REPORT and normalize its results. The adapter must validate returned bounds and expansion completeness, not assume support because the request succeeded. Required cases include RANGE=THISANDFUTURE. If the server cannot expand a required case, the deployment is incompatible with complete agenda/planning until corrected: calendar_unsupported or explicitly partial agenda, never master-only output presented as complete." From `docs/technical/cli-api.md` (*Error identifiers*, lines 118–120): > | Code | Meaning / handling | > |---|---| > | `dependency_unavailable` | Named backend unreachable/unhealthy; do not infer empty state | > | `calendar_unsupported` | Cannot interpret/preserve required semantics | > | `limit_exceeded`, `cursor_expired` | Narrow range or restart paginated query | From `docs/technical/verification.md` (*Calendar scenarios*, lines 33–42): > | ID | Input / failure | Observable acceptance | > |---|---|---| > | `C01` | Complete empty agenda versus unreachable Radicale | Empty complete success versus dependency_unavailable; never the same response | > | `C03` | America/New_York 2030-11-03 01:30 and 2030-03-10 02:30 as new local inputs | Fold requires offset/choice; gap requires clarification, no automatic shift | > | `C04` | All-day DTSTART 20300408 / DTEND 20300409 | Occupies exactly April 8 in configured zone; exclusive boundary | > | `C08` | Infinite recurrence, >93-day query or >10,000 instances | Explicit limit_exceeded and smaller-range instruction, no silent truncation | --- ### Protocol Specifications & XML Envelopes #### 1. Bounded `REPORT calendar-query` Request Envelope (RFC 4791) ```http REPORT /user/calendars/personal/ HTTP/1.1 Host: radicale.example.internal Depth: 1 Prefer: return=minimal Content-Type: application/xml; charset=utf-8 Authorization: Basic <base64(user:pass)> <?xml version="1.0" encoding="utf-8" ?> <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:prop> <D:getetag/> <C:calendar-data> <C:expand start="20300408T000000Z" end="20300415T000000Z"/> </C:calendar-data> </D:prop> <C:filter> <C:comp-filter name="VCALENDAR"> <C:comp-filter name="VEVENT"> <C:time-range start="20300408T000000Z" end="20300415T000000Z"/> </C:comp-filter> </C:comp-filter> </C:filter> </C:calendar-query> ``` #### 2. Expected `207 Multi-Status` with Expanded ICS Data ```xml <?xml version="1.0" encoding="utf-8" ?> <D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> <D:response> <D:href>/user/calendars/personal/event-123.ics</D:href> <D:propstat> <D:prop> <D:getetag>"etag-v1"</D:getetag> <C:calendar-data>BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Radicale//NONSGML Radicale Server//EN BEGIN:VEVENT UID:event-123 DTSTART:20300408T090000Z DTEND:20300408T100000Z SUMMARY:Architecture sync STATUS:CONFIRMED TRANSP:OPAQUE END:VEVENT END:VCALENDAR</C:calendar-data> </D:prop> <D:status>HTTP/1.1 200 OK</D:status> </D:propstat> </D:response> </D:multistatus> ``` #### 3. Error JSON Response: `limit_exceeded` ```json { "schema_version": 1, "request_id": "req_query_overflow", "status": "error", "data": null, "warnings": [], "error": { "code": "limit_exceeded", "message": "Requested query span 120 days exceeds maximum supported limit of 93 days; narrow query range", "retryable": false } } ``` #### 4. Error JSON Response: `dependency_unavailable` (Scenario C01) ```json { "schema_version": 1, "request_id": "req_demo_unavailable", "status": "error", "data": { "as_of": "2030-04-08T06:20:00Z", "complete": false, "coverage": [ { "collection_id": "personal", "state": "unavailable" } ] }, "warnings": [], "error": { "code": "dependency_unavailable", "message": "Calendar backend unreachable; connection refused", "retryable": true } } ``` --- ## What to Build (Step-by-Step) ### 1. Timezone & Range Calculation (`src/calendar/timezone.rs`) - Model `CalendarTimezone`: - Wraps `chrono_tz::Tz`, parsed strictly from `[radicale.timezone]`. - Provide resolution functions for standard named query periods: - `period_today(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval`: Local midnight today to local midnight tomorrow. - `period_tomorrow(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval`: Local midnight tomorrow to local midnight day after tomorrow. - `period_week(tz: Tz, now_utc: DateTime<Utc>) -> HalfOpenInterval`: Local Monday 00:00:00 to following Monday 00:00:00. - Implement custom `from_to(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<HalfOpenInterval, TimeError>`: - Verify `start < end`. - Check duration: if `(end - start) > 93 days`, return `TimeError::LimitExceeded("Query range exceeds 93 days")`. - Tagged Temporal Structs: ```rust #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "value")] pub enum TaggedTime { #[serde(rename = "date")] Date(NaiveDate), #[serde(rename = "instant")] Instant(DateTime<Utc>), } ``` - DST Handling: - Implement fold/gap verification for user-supplied local datetimes using `chrono::TimeZone::from_local_datetime`. - If ambiguous (fold), require explicit offset; if nonexistent (gap), reject with `invalid_input` explaining that the local time does not exist due to DST transition. ### 2. CalDAV `REPORT` XML Builder (`src/calendar/report.rs`) - Function `build_calendar_query_xml(interval: &HalfOpenInterval, component_types: &[ComponentKind]) -> String`: - Construct XML with `<C:calendar-query>` requesting `<D:getetag/>` and `<C:calendar-data>`. - Include `<C:expand start="YYYYMMDDTHHMMSSZ" end="YYYYMMDDTHHMMSSZ"/>`. - Filter on `<C:comp-filter name="VCALENDAR">` with child `<C:comp-filter name="VEVENT">` and/or `VTODO` containing `<C:time-range start="..." end="..."/>`. - Dispatch request via `CalDavClient`: - Send HTTP `REPORT` to collection URL with header `Depth: 1` and `Content-Type: application/xml; charset=utf-8`. - If server returns HTTP 5xx or connection error, map to `CalDavError::DependencyUnavailable`. ### 3. Response Parsing & Instance Count Enforcement (`src/calendar/expansion.rs`) - Parse the `207 Multi-Status` response using `quick-xml`: - Extract each `<D:response>`, identifying the resource `href`, `D:getetag`, and `<C:calendar-data>`. - Parse raw calendar data using `ical::PropertyParser` into individual expanded `VEVENT` and `VTODO` components. - Count total expanded instances: - If instance count > 10,000, abort parsing and immediately return `CalDavError::LimitExceeded("Expanded instance count exceeded 10,000")`. - Normalize component time properties: - For all-day events (`VALUE=DATE`): parse `DTSTART` and `DTEND` as `NaiveDate`. Ensure `DTEND` is treated as exclusive. - For UTC/TZID datetimes: parse into `DateTime<Utc>`. - For floating datetimes: interpret in configured `CalendarTimezone`. - Preserve `RECURRENCE-ID` if present, including its timezone identity. --- ## Acceptance Criteria - [x] **Mandatory Timezone:** Missing or unparseable IANA timezone string in `[radicale]` fails startup with `configuration_invalid`; the system never falls back to local machine timezone. - [x] **Period Boundary Calculations (Scenario W03):** `today`, `tomorrow`, and `week` resolve to exact local-midnight-to-local-midnight half-open intervals in the configured timezone, converted accurately to UTC. - [x] **All-Day Event Exclusivity (Scenario C04):** An all-day event with `DTSTART:20300408` and `DTEND:20300409` is normalized such that it occupies strictly April 8, ending at local midnight on April 9. - [x] **DST Fold & Gap Behavior (Scenario C03):** - Parsing a local datetime falling in a DST fall-back fold without an explicit offset returns `clarification_required` / `invalid_input`. - Parsing a local datetime falling in a DST spring-forward gap is rejected rather than automatically shifted. - [x] **Span Ceiling Enforcement (Scenario C08):** Requesting a query range greater than 93 days immediately returns `limit_exceeded` (HTTP 422, exit code 2) with instructions to query a narrower range. - [x] **Instance Ceiling Enforcement (Scenario C08):** If an infinite recurrence series or dense calendar generates > 10,000 expanded instances in the server response, the parser aborts and returns `limit_exceeded`. - [x] **Backend Outage vs Empty Calendar (Scenario C01):** When Radicale is stopped or connection drops, the service returns `dependency_unavailable` (HTTP 503, exit code 5); it never returns an empty agenda (`items: []`). - [x] **Server-Side Expansion:** CalDAV `REPORT` XML payload contains `<C:expand start="..." end="..."/>` and correctly normalizes expanded occurrences returned by Radicale. --- ## Blocked by - #007: CalDAV Connection Pooling, PROPFIND Discovery & Capabilities
yuris closed this issue 2026-09-22 19:18:48 +00:00
yuris changed title from Issue #008: Bounded CalDAV REPORT Calendar-Query & Timezone Expansion to Bounded CalDAV REPORT Calendar-Query & Timezone Expansion 2026-09-22 20:02:39 +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#8
No description provided.