Scoped Recurring Mutations (Series vs Occurrence) #15

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

Summary

Implement explicit mutation scoping (scope: series vs scope: occurrence) for recurring items, supporting detached occurrence updates and completing individual recurring task occurrences without mutating the master series.

Background & Technical Rationale

In calendar and task management systems supporting recurrence (RFC 5545), mutating an item that belongs to a recurring series presents an inherent semantic ambiguity:

  • Does the user intend to modify the recurrence rule / template itself (affecting all past and future occurrences)?
  • Or does the user intend to modify or complete only this specific occurrence (e.g., rescheduling today's workout or completing today's recurring daily review task)?

If an automation system guesses this intent, it risks destructive consequences—either completing an entire recurring task series when the user only finished today's instance, or inadvertently modifying only a single occurrence when the user intended to reschedule their recurring weekly sync.

To guarantee determinism and user agency, Life enforces strict mutation scoping rules:

  1. Explicit Scope Invariant:
    Whenever a mutation (update, complete, reopen, cancel) targets an item with recurrence rules (RRULE, RDATE) or an existing recurrence identity, the caller MUST explicitly supply scope: series or scope: occurrence.
  2. Ambiguity Clarification:
    If a mutation on a recurring item omits --scope (or the scope field in JSON request payloads), Life MUST NOT guess or default to series or occurrence. It must halt execution and return needs_clarification (HTTP 200 with clarification questions / CLI exit code 3).
  3. CalDAV Single-Resource Transport Mechanics:
    In CalDAV (RFC 4791), a recurring series and all of its detached overrides reside in a single resource (.ics file) identified by one URL href and protected by one ETag:
    • When updating with scope: series, the master VEVENT or VTODO component is patched, and unrelated detached overrides are preserved.
    • When updating with scope: occurrence, the system targets the specific RECURRENCE-ID. If a detached component already exists, it is patched. If no detached component exists yet, a new detached component is cloned from the master, assigned the target RECURRENCE-ID, patched with the requested changes, and injected into the same resource.
    • The entire modified multi-component resource is submitted to Radicale via HTTP PUT using the single resource ETag with If-Match.
  4. Task Occurrence Completion Invariant (Scenario C12):
    Completing a single occurrence of a recurring task (life task complete <id> --etag <etag> --scope occurrence) must complete only that occurrence. The detached component receives STATUS: COMPLETED and a COMPLETED timestamp, while the master VTODO MUST remain in STATUS: NEEDS-ACTION. If the underlying CalDAV server does not support task recurrence conformance, the operation must return calendar_unsupported rather than corrupting the series.
  5. No Automatic Split-Series Workaround:
    Complex range operations such as RANGE=THISANDFUTURE (which split an ongoing series into two separate UID series) are error-prone and inconsistently supported across CalDAV backends. Life explicitly rejects split-series operations with calendar_unsupported.

Exact Technical References & Specifications

Recurrence Mutation Contract

From docs/technical/calendar.md (lines 35–36):

"Recurring mutation requires explicit series/occurrence scope. An occurrence change patches/adds a detached component with the original recurrence identity in the same resource under the resource ETag. Series updates preserve unrelated overrides and reject combinations the adapter cannot safely reason about. No automatic split-series workaround. For recurring tasks, completing one occurrence must not complete the master; support is gated separately by task-recurrence conformance."

Domain Model Recurrence Rules

From docs/technical/domain-model.md (lines 49–50):

"For recurrence mutation the caller must choose scope: series or scope: occurrence. Missing scope on recurring items requires clarification. Occurrence edits preserve the entire resource and other overrides. Unsupported range operations are refused rather than applied to the whole series."

Verification Matrix Scenario C12

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

ID Input / failure Observable acceptance
C12 Recurring task occurrence completion with ETag and operation ID Only specified occurrence changes; unsupported task recurrence blocks feature

CLI & API Envelopes and Exit Codes

From docs/technical/cli-api.md:

  • Exit Code 3 mapping (lines 11–15):

    "3 | Clarification or approval required | 200 with needs_clarification; 403 approval_required"

  • Command-to-Route Table (lines 33–36, 83–86):
    • life task update ID --etag ETAG --input - (PATCH /v1/tasks/{id}): accepts scope: series|occurrence.
    • life task complete ID --etag ETAG --input - (POST /v1/tasks/{id}/complete): accepts scope: series|occurrence.
    • life event update ID --etag ETAG --input - (PATCH /v1/events/{id}): accepts scope: series|occurrence.
    • life event cancel ID --etag ETAG --input - (POST /v1/events/{id}/cancel): accepts scope: series|occurrence.
  • Normative needs_clarification response when --scope is omitted:
    {
      "schema_version": 1,
      "request_id": "req_clarify_recurrence",
      "status": "needs_clarification",
      "data": {
        "item_id": "itm_recurring_task",
        "questions": [
          {
            "id": "scope",
            "prompt": "This item belongs to a recurring series. Apply change to the entire series or only this occurrence?",
            "options": ["series", "occurrence"]
          }
        ]
      },
      "warnings": [],
      "error": null
    }
    

Section Citations

  • docs/technical/calendar.md: Section Recurrence and mutation (lines 35–36), Section Conformance gates (line 49).
  • docs/technical/domain-model.md: Section Identity and relationships (lines 49–50).
  • docs/technical/verification.md: Section Calendar scenarios (lines 46, 50, Scenario C12).
  • docs/technical/cli-api.md: Section Transport and common contract (lines 9–15), Section Command-to-route table (lines 33–36, 83–86), Section Error identifiers (line 113).

What to Build

1. Scoped Mutation Resolver (src/calendar/scoped_mutation.rs)

  • Define the mutation scope enum:
    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    #[serde(rename_all = "snake_case")]
    pub enum MutationScope {
        Series,
        Occurrence,
    }
    
  • Implement validate_mutation_scope(item: &Item, raw_resource: &str, requested_scope: Option<MutationScope>) -> Result<MutationScope, ScopeResolutionError>:
    • Inspect the raw resource to detect if it contains recurrence rules (RRULE, RDATE) or existing detached overrides (RECURRENCE-ID).
    • If the item is non-recurring:
      • If requested_scope is provided as Series or Occurrence, accept or ignore safely.
    • If the item is recurring:
      • If requested_scope is None: return ScopeResolutionError::NeedsClarification containing the structured question envelope.
      • If requested_scope is Some(scope): proceed with the chosen scope.

2. Series Mutation Processor

  • When scope == MutationScope::Series:
    • Locate the master component (component lacking RECURRENCE-ID).
    • Apply property updates directly to the master component using ICalendarPatcher.
    • Verify that sibling detached override components sharing the UID are not removed or corrupted.
    • If an update alters core recurrence parameters in a way that invalidates existing detached overrides and the adapter cannot safely reason about the outcome, abort with CalendarError::Unsupported("calendar_unsupported").

3. Occurrence Mutation & Task Completion Processor

  • When scope == MutationScope::Occurrence:
    • Extract the targeted RECURRENCE-ID from the request or the targeted item projection.
    • Parse the raw VCALENDAR stream.
    • Check if a detached component already exists with RECURRENCE-ID == target_recurrence_id.
      • If yes: apply the patch directly to that detached component.
      • If no:
        • Clone the master component.
        • Remove any RRULE or RDATE properties from the cloned component.
        • Add RECURRENCE-ID property matching the target occurrence timestamp and timezone.
        • Materialize occurrence-specific temporal properties: rebase DTSTART, DTEND, and/or DUE to the target occurrence's scheduled instant/date, preserving the series duration and time-of-day.
        • Apply the requested patch to the cloned component.
        • Append the new detached component to the VCALENDAR component list.
    • Occurrence Task Completion (Scenario C12):
      • For life task complete <id> --etag <etag> --scope occurrence:
        • Set STATUS: COMPLETED on the detached component.
        • Set COMPLETED: <timestamp> on the detached component.
        • Crucial Invariant: The master VTODO MUST remain untouched with STATUS: NEEDS-ACTION.
    • Submit the entire modified multi-component resource back to Radicale using HTTP PUT with If-Match: <resource_etag>.

4. CLI Flags and HTTP Route Handlers

  • Update CLI commands in src/cli.rs:
    • Add optional --scope <series|occurrence> argument to:
      • life task update
      • life task complete
      • life task reopen
      • life task cancel
      • life event update
      • life event cancel
  • In Axum route handlers (src/api.rs):
    • Parse scope from request JSON body.
    • When ScopeResolutionError::NeedsClarification is returned, respond with HTTP 200 and envelope status: "needs_clarification".
    • Ensure CLI terminates with exit code 3 on needs_clarification.

5. Integration Test Suite (tests/calendar_scoped_mutation_tests.rs)

  • Test 1 (Clarification trigger):
    • Target a recurring task without passing --scope.
    • Assert response is HTTP 200 / CLI exit 3 with status: "needs_clarification" and clarification options ["series", "occurrence"].
  • Test 2 (Occurrence completion, Scenario C12):
    • Complete occurrence 20300410T090000Z of a recurring task with --scope occurrence.
    • Inspect the updated raw resource in Radicale:
      • Assert a detached VTODO exists with RECURRENCE-ID: 20300410T090000Z and STATUS: COMPLETED.
      • Assert the master VTODO exists with STATUS: NEEDS-ACTION.
  • Test 3 (Series update):
    • Update SUMMARY of recurring series with --scope series.
    • Assert master SUMMARY is updated while unrelated detached overrides retain their identities.
  • Test 4 (Split-series rejection):
    • Attempt range mutation (RANGE=THISANDFUTURE).
    • Assert mutation is refused with calendar_unsupported.

Acceptance Criteria

  • Attempting to update or complete a recurring task or event without providing --scope returns needs_clarification (HTTP 200, CLI exit code 3) with structured questions (Scenario C12).
  • Completing an occurrence of a recurring task with --scope occurrence updates only that occurrence component to STATUS: COMPLETED while the master VTODO remains in STATUS: NEEDS-ACTION (Scenario C12).
  • Creating an occurrence override on an un-overridden instance generates a new detached component with the exact matching RECURRENCE-ID and preserves all other occurrences and master properties.
  • Occurrence Time Materialization: Creating a detached occurrence override for a non-temporal edit (e.g. title update) on a later occurrence correctly rebases DTSTART/DTEND/DUE to that occurrence's scheduled time, rather than leaving the master's initial start date.
  • Updating a recurring item with --scope series mutates the master component while leaving unrelated existing detached overrides intact.
  • Range operations (RANGE=THISANDFUTURE) and split-series mutations are rejected with explicit calendar_unsupported error (Exit code 2 / 5).
  • All mutations are submitted under the single resource ETag with If-Match.

Notes

  • One review round, integration only: the branch was reset onto current main and re-verified against the jev and hindsight suites (374 tests). No code change was required.
  • No dependency added, so the deferred Cargo.toml authors chore stays pending.

Blocked by

  • #014: Preservation-Safe iCalendar Patcher
## Summary Implement explicit mutation scoping (`scope: series` vs `scope: occurrence`) for recurring items, supporting detached occurrence updates and completing individual recurring task occurrences without mutating the master series. ## Background & Technical Rationale In calendar and task management systems supporting recurrence (RFC 5545), mutating an item that belongs to a recurring series presents an inherent semantic ambiguity: - Does the user intend to modify the recurrence rule / template itself (affecting all past and future occurrences)? - Or does the user intend to modify or complete only this specific occurrence (e.g., rescheduling today's workout or completing today's recurring daily review task)? If an automation system guesses this intent, it risks destructive consequences—either completing an entire recurring task series when the user only finished today's instance, or inadvertently modifying only a single occurrence when the user intended to reschedule their recurring weekly sync. To guarantee determinism and user agency, Life enforces strict mutation scoping rules: 1. **Explicit Scope Invariant:** Whenever a mutation (`update`, `complete`, `reopen`, `cancel`) targets an item with recurrence rules (`RRULE`, `RDATE`) or an existing recurrence identity, the caller MUST explicitly supply `scope: series` or `scope: occurrence`. 2. **Ambiguity Clarification:** If a mutation on a recurring item omits `--scope` (or the `scope` field in JSON request payloads), Life MUST NOT guess or default to series or occurrence. It must halt execution and return `needs_clarification` (HTTP 200 with clarification questions / CLI exit code 3). 3. **CalDAV Single-Resource Transport Mechanics:** In CalDAV (RFC 4791), a recurring series and all of its detached overrides reside in a *single* resource (`.ics` file) identified by one URL href and protected by one ETag: - When updating with `scope: series`, the master `VEVENT` or `VTODO` component is patched, and unrelated detached overrides are preserved. - When updating with `scope: occurrence`, the system targets the specific `RECURRENCE-ID`. If a detached component already exists, it is patched. If no detached component exists yet, a new detached component is cloned from the master, assigned the target `RECURRENCE-ID`, patched with the requested changes, and injected into the same resource. - The entire modified multi-component resource is submitted to Radicale via HTTP `PUT` using the single resource ETag with `If-Match`. 4. **Task Occurrence Completion Invariant (Scenario C12):** Completing a single occurrence of a recurring task (`life task complete <id> --etag <etag> --scope occurrence`) must complete *only* that occurrence. The detached component receives `STATUS: COMPLETED` and a `COMPLETED` timestamp, while the master `VTODO` MUST remain in `STATUS: NEEDS-ACTION`. If the underlying CalDAV server does not support task recurrence conformance, the operation must return `calendar_unsupported` rather than corrupting the series. 5. **No Automatic Split-Series Workaround:** Complex range operations such as `RANGE=THISANDFUTURE` (which split an ongoing series into two separate UID series) are error-prone and inconsistently supported across CalDAV backends. Life explicitly rejects split-series operations with `calendar_unsupported`. ## Exact Technical References & Specifications ### Recurrence Mutation Contract From `docs/technical/calendar.md` (lines 35–36): > "Recurring mutation requires explicit series/occurrence scope. An occurrence change patches/adds a detached component with the original recurrence identity in the same resource under the resource ETag. Series updates preserve unrelated overrides and reject combinations the adapter cannot safely reason about. No automatic split-series workaround. For recurring tasks, completing one occurrence must not complete the master; support is gated separately by task-recurrence conformance." ### Domain Model Recurrence Rules From `docs/technical/domain-model.md` (lines 49–50): > "For recurrence mutation the caller must choose `scope: series` or `scope: occurrence`. Missing scope on recurring items requires clarification. Occurrence edits preserve the entire resource and other overrides. Unsupported range operations are refused rather than applied to the whole series." ### Verification Matrix Scenario C12 From `docs/technical/verification.md` (lines 46, 50): | ID | Input / failure | Observable acceptance | |---|---|---| | C12 | Recurring task occurrence completion with ETag and operation ID | Only specified occurrence changes; unsupported task recurrence blocks feature | ### CLI & API Envelopes and Exit Codes From `docs/technical/cli-api.md`: - Exit Code 3 mapping (lines 11–15): > "3 | Clarification or approval required | 200 with needs_clarification; 403 approval_required" - Command-to-Route Table (lines 33–36, 83–86): - `life task update ID --etag ETAG --input -` (PATCH `/v1/tasks/{id}`): accepts `scope: series|occurrence`. - `life task complete ID --etag ETAG --input -` (POST `/v1/tasks/{id}/complete`): accepts `scope: series|occurrence`. - `life event update ID --etag ETAG --input -` (PATCH `/v1/events/{id}`): accepts `scope: series|occurrence`. - `life event cancel ID --etag ETAG --input -` (POST `/v1/events/{id}/cancel`): accepts `scope: series|occurrence`. - Normative `needs_clarification` response when `--scope` is omitted: ```json { "schema_version": 1, "request_id": "req_clarify_recurrence", "status": "needs_clarification", "data": { "item_id": "itm_recurring_task", "questions": [ { "id": "scope", "prompt": "This item belongs to a recurring series. Apply change to the entire series or only this occurrence?", "options": ["series", "occurrence"] } ] }, "warnings": [], "error": null } ``` ### Section Citations - `docs/technical/calendar.md`: Section *Recurrence and mutation* (lines 35–36), Section *Conformance gates* (line 49). - `docs/technical/domain-model.md`: Section *Identity and relationships* (lines 49–50). - `docs/technical/verification.md`: Section *Calendar scenarios* (lines 46, 50, Scenario C12). - `docs/technical/cli-api.md`: Section *Transport and common contract* (lines 9–15), Section *Command-to-route table* (lines 33–36, 83–86), Section *Error identifiers* (line 113). ## What to Build ### 1. Scoped Mutation Resolver (`src/calendar/scoped_mutation.rs`) - Define the mutation scope enum: ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MutationScope { Series, Occurrence, } ``` - Implement `validate_mutation_scope(item: &Item, raw_resource: &str, requested_scope: Option<MutationScope>) -> Result<MutationScope, ScopeResolutionError>`: - Inspect the raw resource to detect if it contains recurrence rules (`RRULE`, `RDATE`) or existing detached overrides (`RECURRENCE-ID`). - If the item is non-recurring: - If `requested_scope` is provided as `Series` or `Occurrence`, accept or ignore safely. - If the item is recurring: - If `requested_scope` is `None`: return `ScopeResolutionError::NeedsClarification` containing the structured question envelope. - If `requested_scope` is `Some(scope)`: proceed with the chosen scope. ### 2. Series Mutation Processor - When `scope == MutationScope::Series`: - Locate the master component (component lacking `RECURRENCE-ID`). - Apply property updates directly to the master component using `ICalendarPatcher`. - Verify that sibling detached override components sharing the `UID` are not removed or corrupted. - If an update alters core recurrence parameters in a way that invalidates existing detached overrides and the adapter cannot safely reason about the outcome, abort with `CalendarError::Unsupported("calendar_unsupported")`. ### 3. Occurrence Mutation & Task Completion Processor - When `scope == MutationScope::Occurrence`: - Extract the targeted `RECURRENCE-ID` from the request or the targeted item projection. - Parse the raw `VCALENDAR` stream. - Check if a detached component already exists with `RECURRENCE-ID == target_recurrence_id`. - If yes: apply the patch directly to that detached component. - If no: - Clone the master component. - Remove any `RRULE` or `RDATE` properties from the cloned component. - Add `RECURRENCE-ID` property matching the target occurrence timestamp and timezone. - Materialize occurrence-specific temporal properties: rebase `DTSTART`, `DTEND`, and/or `DUE` to the target occurrence's scheduled instant/date, preserving the series duration and time-of-day. - Apply the requested patch to the cloned component. - Append the new detached component to the `VCALENDAR` component list. - Occurrence Task Completion (Scenario C12): - For `life task complete <id> --etag <etag> --scope occurrence`: - Set `STATUS: COMPLETED` on the detached component. - Set `COMPLETED: <timestamp>` on the detached component. - Crucial Invariant: The master `VTODO` MUST remain untouched with `STATUS: NEEDS-ACTION`. - Submit the entire modified multi-component resource back to Radicale using HTTP `PUT` with `If-Match: <resource_etag>`. ### 4. CLI Flags and HTTP Route Handlers - Update CLI commands in `src/cli.rs`: - Add optional `--scope <series|occurrence>` argument to: - `life task update` - `life task complete` - `life task reopen` - `life task cancel` - `life event update` - `life event cancel` - In Axum route handlers (`src/api.rs`): - Parse `scope` from request JSON body. - When `ScopeResolutionError::NeedsClarification` is returned, respond with HTTP 200 and envelope `status: "needs_clarification"`. - Ensure CLI terminates with exit code 3 on `needs_clarification`. ### 5. Integration Test Suite (`tests/calendar_scoped_mutation_tests.rs`) - Test 1 (Clarification trigger): - Target a recurring task without passing `--scope`. - Assert response is HTTP 200 / CLI exit 3 with `status: "needs_clarification"` and clarification options `["series", "occurrence"]`. - Test 2 (Occurrence completion, Scenario C12): - Complete occurrence `20300410T090000Z` of a recurring task with `--scope occurrence`. - Inspect the updated raw resource in Radicale: - Assert a detached `VTODO` exists with `RECURRENCE-ID: 20300410T090000Z` and `STATUS: COMPLETED`. - Assert the master `VTODO` exists with `STATUS: NEEDS-ACTION`. - Test 3 (Series update): - Update `SUMMARY` of recurring series with `--scope series`. - Assert master `SUMMARY` is updated while unrelated detached overrides retain their identities. - Test 4 (Split-series rejection): - Attempt range mutation (`RANGE=THISANDFUTURE`). - Assert mutation is refused with `calendar_unsupported`. ## Acceptance Criteria - [x] Attempting to update or complete a recurring task or event without providing `--scope` returns `needs_clarification` (HTTP 200, CLI exit code 3) with structured questions (Scenario C12). - [x] Completing an occurrence of a recurring task with `--scope occurrence` updates only that occurrence component to `STATUS: COMPLETED` while the master `VTODO` remains in `STATUS: NEEDS-ACTION` (Scenario C12). - [x] Creating an occurrence override on an un-overridden instance generates a new detached component with the exact matching `RECURRENCE-ID` and preserves all other occurrences and master properties. - [x] Occurrence Time Materialization: Creating a detached occurrence override for a non-temporal edit (e.g. title update) on a later occurrence correctly rebases DTSTART/DTEND/DUE to that occurrence's scheduled time, rather than leaving the master's initial start date. - [x] Updating a recurring item with `--scope series` mutates the master component while leaving unrelated existing detached overrides intact. - [x] Range operations (`RANGE=THISANDFUTURE`) and split-series mutations are rejected with explicit `calendar_unsupported` error (Exit code 2 / 5). - [x] All mutations are submitted under the single resource ETag with `If-Match`. ## Notes - One review round, integration only: the branch was reset onto current `main` and re-verified against the jev and hindsight suites (374 tests). No code change was required. - No dependency added, so the deferred `Cargo.toml` authors chore stays pending. ## Blocked by - #014: Preservation-Safe iCalendar Patcher
yuris changed title from Issue #015: Scoped Recurring Mutations (Series vs Occurrence) to Scoped Recurring Mutations (Series vs Occurrence) 2026-09-22 20:02:38 +00:00
yuris closed this issue 2026-09-22 23:08:54 +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#15
No description provided.