feat(calendar): scoped recurring mutations for series and occurrences #56

Merged
yuris merged 1 commit from feat/issue-15-scope into main 2026-09-22 23:08:53 +00:00
Owner

Motivation

Mutating an item that belongs to a recurring series is ambiguous in a way no default resolves safely: patching the master reschedules every occurrence, while patching one occurrence leaves the series alone. Until now the service sidestepped the question by refusing any item whose recurrence_id was set (calendar_unsupported), which made the flagship case — completing today's occurrence of a recurring task — impossible. docs/technical/calendar.md#recurrence-and-mutation and docs/technical/domain-model.md#identity-and-relationships require the caller to choose scope: series or scope: occurrence, and require the service to ask rather than guess when the choice is missing. This is the last slice of Phase 2 (scenario C12).

Changes

  • src/calendar/scoped_mutation.rs (new): the scope vocabulary. MutationScope::{Series, Occurrence}, resolve_scope (a non-recurring item is its own series, so no scope is required; a recurring one without a caller-chosen scope is NeedsClarification), reject_split_series (a RECURRENCE-ID;RANGE=THISANDFUTURE/THISANDPRIOR series is refused, never applied to the whole series and never approximated to one occurrence), and the occurrence writers: recurrence_id_edit and materialized_times render the occurrence's own identity and rebased DTSTART/DTEND/DUE, preserving the series' value type and timezone (VALUE=DATE stays a date, a TZID series keeps its wall clock, a floating series stays floating, a UTC instant stays UTC). Only the temporal properties the master declares are written, so the parser's derived exclusive all-day DTEND is never invented back into a VTODO that has none.
  • src/calendar/preservation.rs: the patcher is split into locate/target_span/rewrite_body/assemble, and gains component_exists plus add_detached_component, which appends a clone of the master carrying the occurrence's RECURRENCE-ID, without the series' RRULE/RDATE/EXDATE. Every untouched physical line is still re-emitted verbatim; the master and unrelated overrides stay byte-identical.
  • src/calendar/recurrence.rs: claim_occurrence enumerates a series' identities so an occurrence the resource materializes from its rule — rather than stores as a component — is still addressable by the opaque id a read handed out. Expansion semantics are untouched (the change is purely additive).
  • src/api/items.rs: scan_collection and component_in_body fall back to that enumeration when no stored component carries the id, so GET /v1/items/{id} and every mutation agree on what an occurrence id names.
  • src/api/mod.rs: ScopedTarget/resolve_scoped_target resolve which component a mutation acts on and refuse the unrepresentable combinations before anything is planned or dispatched; clarification_response returns the structured scope question (HTTP 200, status: needs_clarification).
  • src/api/update.rs / src/api/lifecycle.rs: the flat occurrence refusal is replaced by scope handling. series rewrites the master's own lines; occurrence rewrites the detached component carrying the identity, or creates it from the master when the resource does not store one yet. The write is still one conditional PUT under the caller's exact quoted If-Match (unchanged required_if_match), still prepared-then-dispatched through the journal, and the confirmed readback, event and projection now carry the occurrence's identity.
  • src/domain/patch.rs / src/domain/lifecycle.rs: scope on TaskPatch, EventPatch and LifecycleInput, bound into the idempotency fingerprint so the same key with a different scope is an idempotency_conflict. An unknown scope value is still rejected by serde before any dispatch.
  • src/cli.rs: --scope series|occurrence on task update/complete/reopen/cancel and event update/cancel; the value is carried into the request body (a contradicting body scope is refused locally), an unknown value never leaves the process, and a clarification envelope is printed as the question with its options.

Verification

nix develop --command cargo test — 284 tests pass across 20 binaries, 0 failures. cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.

New suite tests/scoped_recurrence_tests.rs (28 tests, mock Radicale over TCP, asserting on the exact bytes PUT and on the CLI exit codes):

  • the refusal matrix: no scope on a recurring master, on a stored occurrence, and on a recurring task completion returns HTTP 200 needs_clarification with options: [series, occurrence], zero PUTs, zero recorded operations and an untouched resource; a non-recurring item still mutates with no scope and also accepts an explicit one; an unknown scope value (this-occurrence, SERIES, all, empty) is 400 invalid_input before the backend is contacted; scope: occurrence on a master id is refused;
  • scenario C12: completing a virtual occurrence writes exactly one detached VTODO with RECURRENCE-ID;VALUE=DATE:20300403, STATUS:COMPLETED, COMPLETED, PERCENT-COMPLETE:100 and materialized DTSTART/DUE, while the master component is byte-identical, still STATUS:NEEDS-ACTION, and still the only component carrying the rule; reopening a stored occurrence patches only that component and clears its completion;
  • override creation: a title edit on an un-overridden occurrence appends the exact expected component (identity, rebased times, master properties, no rule of its own) and leaves the master byte-identical; a temporal edit keeps the caller's own deadline while still materializing the start; a TZID=Europe/Paris series keeps its wall clock and TZID; an existing override is patched in place;
  • series scope: the master changes while an unrelated override stays byte-identical, including when the caller addresses the series by an occurrence id;
  • refusals: RANGE=THISANDFUTURE is 422 calendar_unsupported for both scopes and for a lifecycle transition, with no write and no operation;
  • preconditions and idempotency: missing/weak/unquoted/*/multi-tag ETags are 400 with zero requests, a replayed occurrence mutation does not re-dispatch, the same key with a different scope is a 409 idempotency_conflict, and a 412 is reported as etag_conflict with the operation id and is never retried;
  • CLI: exit 3 with the structured question, exit 2 for calendar_unsupported, exit 0 on success with the scope carried in the body, exit 2 locally for an unknown --scope value, and two full-stack tests (CLI → daemon → mock Radicale) proving occurrence completion and the clarification round trip end to end.

Two existing tests were updated because this issue deliberately replaces the behaviour they pinned: an_occurrence_scoped_item_is_refused_rather_than_guessed (tests/update_tests.rs) and an_occurrence_is_refused_rather_than_transitioned_as_a_series (tests/lifecycle_tests.rs) now assert the scope question instead of the flat refusal. Every other suite is unchanged and green.

Known limitation

An occurrence id is resolved by enumerating the series within a bounded horizon of ten years from the series' own anchor (OCCURRENCE_RESOLUTION_HORIZON_DAYS), which keeps the walk inside the documented 10,000-instance ceiling for every frequency this service expands. A series that runs longer than that, or a caller reading an agenda window beyond it, cannot name an occurrence by id; the id then resolves as absent rather than being guessed. Bounded series (COUNT/UNTIL) are enumerated exactly, so the horizon only caps rules that never end.

Closes #15
Refs #013, #014

## Motivation Mutating an item that belongs to a recurring series is ambiguous in a way no default resolves safely: patching the master reschedules every occurrence, while patching one occurrence leaves the series alone. Until now the service sidestepped the question by refusing any item whose `recurrence_id` was set (`calendar_unsupported`), which made the flagship case — completing *today's* occurrence of a recurring task — impossible. `docs/technical/calendar.md#recurrence-and-mutation` and `docs/technical/domain-model.md#identity-and-relationships` require the caller to choose `scope: series` or `scope: occurrence`, and require the service to *ask* rather than guess when the choice is missing. This is the last slice of Phase 2 (scenario C12). ## Changes - **`src/calendar/scoped_mutation.rs`** (new): the scope vocabulary. `MutationScope::{Series, Occurrence}`, `resolve_scope` (a non-recurring item is its own series, so no scope is required; a recurring one without a caller-chosen scope is `NeedsClarification`), `reject_split_series` (a `RECURRENCE-ID;RANGE=THISANDFUTURE`/`THISANDPRIOR` series is refused, never applied to the whole series and never approximated to one occurrence), and the occurrence writers: `recurrence_id_edit` and `materialized_times` render the occurrence's own identity and rebased `DTSTART`/`DTEND`/`DUE`, preserving the series' value type and timezone (`VALUE=DATE` stays a date, a `TZID` series keeps its wall clock, a floating series stays floating, a UTC instant stays UTC). Only the temporal properties the master *declares* are written, so the parser's derived exclusive all-day `DTEND` is never invented back into a `VTODO` that has none. - **`src/calendar/preservation.rs`**: the patcher is split into `locate`/`target_span`/`rewrite_body`/`assemble`, and gains `component_exists` plus `add_detached_component`, which appends a clone of the master carrying the occurrence's `RECURRENCE-ID`, without the series' `RRULE`/`RDATE`/`EXDATE`. Every untouched physical line is still re-emitted verbatim; the master and unrelated overrides stay byte-identical. - **`src/calendar/recurrence.rs`**: `claim_occurrence` enumerates a series' identities so an occurrence the resource materializes from its rule — rather than stores as a component — is still addressable by the opaque id a read handed out. Expansion semantics are untouched (the change is purely additive). - **`src/api/items.rs`**: `scan_collection` and `component_in_body` fall back to that enumeration when no stored component carries the id, so `GET /v1/items/{id}` and every mutation agree on what an occurrence id names. - **`src/api/mod.rs`**: `ScopedTarget`/`resolve_scoped_target` resolve which component a mutation acts on and refuse the unrepresentable combinations before anything is planned or dispatched; `clarification_response` returns the structured scope question (HTTP 200, `status: needs_clarification`). - **`src/api/update.rs` / `src/api/lifecycle.rs`**: the flat occurrence refusal is replaced by scope handling. `series` rewrites the master's own lines; `occurrence` rewrites the detached component carrying the identity, or creates it from the master when the resource does not store one yet. The write is still one conditional `PUT` under the caller's exact quoted `If-Match` (unchanged `required_if_match`), still prepared-then-dispatched through the journal, and the confirmed readback, event and projection now carry the occurrence's identity. - **`src/domain/patch.rs` / `src/domain/lifecycle.rs`**: `scope` on `TaskPatch`, `EventPatch` and `LifecycleInput`, bound into the idempotency fingerprint so the same key with a different scope is an `idempotency_conflict`. An unknown scope value is still rejected by serde before any dispatch. - **`src/cli.rs`**: `--scope series|occurrence` on `task update`/`complete`/`reopen`/`cancel` and `event update`/`cancel`; the value is carried into the request body (a contradicting body `scope` is refused locally), an unknown value never leaves the process, and a clarification envelope is printed as the question with its options. ## Verification `nix develop --command cargo test` — 284 tests pass across 20 binaries, 0 failures. `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` are clean. New suite `tests/scoped_recurrence_tests.rs` (28 tests, mock Radicale over TCP, asserting on the exact bytes `PUT` and on the CLI exit codes): - the refusal matrix: no scope on a recurring master, on a stored occurrence, and on a recurring task completion returns HTTP 200 `needs_clarification` with `options: [series, occurrence]`, zero `PUT`s, zero recorded operations and an untouched resource; a non-recurring item still mutates with no scope and also accepts an explicit one; an unknown scope value (`this-occurrence`, `SERIES`, `all`, empty) is `400 invalid_input` before the backend is contacted; `scope: occurrence` on a master id is refused; - scenario C12: completing a virtual occurrence writes exactly one detached `VTODO` with `RECURRENCE-ID;VALUE=DATE:20300403`, `STATUS:COMPLETED`, `COMPLETED`, `PERCENT-COMPLETE:100` and materialized `DTSTART`/`DUE`, while the master component is byte-identical, still `STATUS:NEEDS-ACTION`, and still the only component carrying the rule; reopening a stored occurrence patches only that component and clears its completion; - override creation: a title edit on an un-overridden occurrence appends the exact expected component (identity, rebased times, master properties, no rule of its own) and leaves the master byte-identical; a temporal edit keeps the caller's own deadline while still materializing the start; a `TZID=Europe/Paris` series keeps its wall clock and `TZID`; an existing override is patched in place; - series scope: the master changes while an unrelated override stays byte-identical, including when the caller addresses the series by an occurrence id; - refusals: `RANGE=THISANDFUTURE` is `422 calendar_unsupported` for both scopes and for a lifecycle transition, with no write and no operation; - preconditions and idempotency: missing/weak/unquoted/`*`/multi-tag ETags are `400` with zero requests, a replayed occurrence mutation does not re-dispatch, the same key with a different scope is a `409 idempotency_conflict`, and a `412` is reported as `etag_conflict` with the operation id and is never retried; - CLI: exit 3 with the structured question, exit 2 for `calendar_unsupported`, exit 0 on success with the scope carried in the body, exit 2 locally for an unknown `--scope` value, and two full-stack tests (CLI → daemon → mock Radicale) proving occurrence completion and the clarification round trip end to end. Two existing tests were updated because this issue deliberately replaces the behaviour they pinned: `an_occurrence_scoped_item_is_refused_rather_than_guessed` (`tests/update_tests.rs`) and `an_occurrence_is_refused_rather_than_transitioned_as_a_series` (`tests/lifecycle_tests.rs`) now assert the scope question instead of the flat refusal. Every other suite is unchanged and green. ## Known limitation An occurrence id is resolved by enumerating the series within a bounded horizon of ten years from the series' own anchor (`OCCURRENCE_RESOLUTION_HORIZON_DAYS`), which keeps the walk inside the documented 10,000-instance ceiling for every frequency this service expands. A series that runs longer than that, or a caller reading an agenda window beyond it, cannot name an occurrence by id; the id then resolves as absent rather than being guessed. Bounded series (`COUNT`/`UNTIL`) are enumerated exactly, so the horizon only caps rules that never end. ## Related Issues Closes #15 Refs #013, #014
Author
Owner

Coordinator review — CHANGES REQUESTED (integration verification only; no code defect found)

I read the diff and ran it on the branch as pushed. On its own base (9ffd57d) it is green: cargo test 284 passed / 0 failed including scoped_recurrence_tests (28), clippy 0 errors, fmt clean.

The implementation is right on the parts that are easy to get wrong:

  • add_detached_component builds the clone with PropertyEdit::remove("RRULE"/"RDATE"/"EXDATE") before applying the caller's edits — a detached override must not repeat the series rule, and inheriting it is the classic bug here.
  • materialized_times writes only the properties the master declares, with a comment explaining that the parser derives an exclusive all-day DTEND and writing that back would invent a property the series never had. It preserves the series' value type and zone (date stays VALUE=DATE, TZID series keeps wall clock and TZID, floating stays floating, UTC stays UTC) — which is exactly criterion 4, and it is covered by an_occurrence_is_materialized_in_the_series_own_value_type_and_zone across all four shapes.
  • reject_split_series refuses RANGE=THISANDFUTURE/THISANDPRIOR rather than approximating, with the reasoning stated (applying it as one occurrence would drop its meaning; applying it to the series would touch instances the caller never named).
  • resolve_scope keeps non-recurring items unaffected (a component that is not part of a series is its own series) while a recurring component without a chosen scope is a question — and the clarification tests assert 200 + needs_clarification, options == [series, occurrence], zero PUTs, zero operations rows and an untouched resource.

Change needed: the branch is two commits behind and has not been verified against current main.

origin/feat/issue-15-scope is based on 9ffd57d, but main is now at 12fcf8d — #54 (src/jev/*) and #55 (src/hindsight/*) merged in between. The branch tree contains neither (git ls-tree -r --name-only origin/feat/issue-15-scope | grep -cE '^src/(hindsight|jev)/' → 0), which shows the pre-push rebase step did not happen, and it means the 342-test suite that exists on main has never run against this change. It also makes the PR diff read as if it deletes 6,580 lines of sibling modules.

Please:

  1. git fetch origin main && git rebase origin/main in .worktrees/issue-15-scope. Keep both sides on any registration conflict in src/lib.rs, src/api/mod.rs or src/domain/mod.rs — the jev and hindsight modules and routes must survive.
  2. Confirm the branch tree now contains src/hindsight/* and src/jev/* (non-zero count) and that the PR diff no longer shows them as deletions.
  3. Re-run the full suite on the rebased tree — it must include jev_fixtures, jev_client_tests and hindsight_client_tests alongside your new suite — plus clippy -D warnings and fmt --check.
  4. Force-push and report the new HEAD, the full test totals, and the module count.

Keep everything else exactly as it is. Do not merge.

## Coordinator review — CHANGES REQUESTED (integration verification only; no code defect found) I read the diff and ran it on the branch as pushed. On its own base (`9ffd57d`) it is green: `cargo test` **284 passed / 0 failed** including `scoped_recurrence_tests` (28), clippy 0 errors, fmt clean. The implementation is right on the parts that are easy to get wrong: - `add_detached_component` builds the clone with `PropertyEdit::remove("RRULE"/"RDATE"/"EXDATE")` before applying the caller's edits — a detached override must not repeat the series rule, and inheriting it is the classic bug here. - `materialized_times` writes only the properties the **master declares**, with a comment explaining that the parser derives an exclusive all-day `DTEND` and writing that back would invent a property the series never had. It preserves the series' value type and zone (date stays `VALUE=DATE`, `TZID` series keeps wall clock and `TZID`, floating stays floating, UTC stays UTC) — which is exactly criterion 4, and it is covered by `an_occurrence_is_materialized_in_the_series_own_value_type_and_zone` across all four shapes. - `reject_split_series` refuses `RANGE=THISANDFUTURE`/`THISANDPRIOR` rather than approximating, with the reasoning stated (applying it as one occurrence would drop its meaning; applying it to the series would touch instances the caller never named). - `resolve_scope` keeps non-recurring items unaffected (a component that is not part of a series *is* its own series) while a recurring component without a chosen scope is a question — and the clarification tests assert 200 + `needs_clarification`, `options == [series, occurrence]`, zero PUTs, zero `operations` rows and an untouched resource. **Change needed: the branch is two commits behind and has not been verified against current `main`.** `origin/feat/issue-15-scope` is based on `9ffd57d`, but `main` is now at `12fcf8d` — #54 (`src/jev/*`) and #55 (`src/hindsight/*`) merged in between. The branch tree contains neither (`git ls-tree -r --name-only origin/feat/issue-15-scope | grep -cE '^src/(hindsight|jev)/'` → `0`), which shows the pre-push rebase step did not happen, and it means the 342-test suite that exists on `main` has never run against this change. It also makes the PR diff read as if it deletes 6,580 lines of sibling modules. Please: 1. `git fetch origin main && git rebase origin/main` in `.worktrees/issue-15-scope`. Keep **both** sides on any registration conflict in `src/lib.rs`, `src/api/mod.rs` or `src/domain/mod.rs` — the jev and hindsight modules and routes must survive. 2. Confirm the branch tree now contains `src/hindsight/*` and `src/jev/*` (non-zero count) and that the PR diff no longer shows them as deletions. 3. Re-run the full suite on the rebased tree — it must include `jev_fixtures`, `jev_client_tests` and `hindsight_client_tests` alongside your new suite — plus `clippy -D warnings` and `fmt --check`. 4. Force-push and report the new HEAD, the full test totals, and the module count. Keep everything else exactly as it is. Do not merge.
yuris force-pushed feat/issue-15-scope from 3588723912 to a09d201207 2026-09-22 23:07:54 +00:00 Compare
Author
Owner

Coordinator re-review — APPROVED

Verified a09d201 myself, including the integration concern that prompted the round trip:

  • origin/feat/issue-15-scope now sits on 12fcf8d (current main).
  • The branch tree contains 7 files under src/hindsight/ and src/jev/; git diff --stat origin/main...origin/feat/issue-15-scope lists only this slice's 14 files, so the earlier apparent 6,580-line deletion of sibling modules is gone.
  • cargo test on the rebased tree: 374 passed / 0 failed across 18 integration binaries — which now includes jev_fixtures, jev_client_tests and hindsight_client_tests alongside scoped_recurrence_tests (28), update_tests, lifecycle_tests and recurrence_tests. cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean.

That closes the only outstanding item; the code itself needed no change. Recording the substance for the merge message: mandatory --scope on recurring items resolving to needs_clarification (200 / exit 3) with [series, occurrence] options and zero dispatches, occurrence-scoped completion patching only the addressed component while the master block stays byte-identical, detached-override creation that drops RRULE/RDATE/EXDATE from the clone and materializes the occurrence's own times in the series' value type and zone, series-scope edits leaving sibling overrides byte-intact, and RANGE=THISANDFUTURE/THISANDPRIOR refused rather than approximated — all under one conditional PUT with the caller's exact quoted If-Match.

Merging. This is the last slice of Phase 2.

## Coordinator re-review — APPROVED Verified `a09d201` myself, including the integration concern that prompted the round trip: - `origin/feat/issue-15-scope` now sits on `12fcf8d` (current `main`). - The branch tree contains 7 files under `src/hindsight/` and `src/jev/`; `git diff --stat origin/main...origin/feat/issue-15-scope` lists only this slice's 14 files, so the earlier apparent 6,580-line deletion of sibling modules is gone. - `cargo test` on the rebased tree: **374 passed / 0 failed** across 18 integration binaries — which now includes `jev_fixtures`, `jev_client_tests` and `hindsight_client_tests` alongside `scoped_recurrence_tests` (28), `update_tests`, `lifecycle_tests` and `recurrence_tests`. `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. That closes the only outstanding item; the code itself needed no change. Recording the substance for the merge message: mandatory `--scope` on recurring items resolving to `needs_clarification` (200 / exit 3) with `[series, occurrence]` options and zero dispatches, occurrence-scoped completion patching only the addressed component while the master block stays byte-identical, detached-override creation that drops `RRULE`/`RDATE`/`EXDATE` from the clone and materializes the occurrence's own times in the series' value type and zone, series-scope edits leaving sibling overrides byte-intact, and `RANGE=THISANDFUTURE`/`THISANDPRIOR` refused rather than approximated — all under one conditional `PUT` with the caller's exact quoted `If-Match`. Merging. This is the last slice of Phase 2.
yuris merged commit 68a87a2381 into main 2026-09-22 23:08:53 +00:00
yuris changed title from feat(calendar): scoped recurring mutations for series and occurrences (#15) to feat(calendar): scoped recurring mutations for series and occurrences 2026-09-23 05:12:05 +00:00
Sign in to join this conversation.
No reviewers
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!56
No description provided.