feat(calendar): scoped recurring mutations for series and occurrences #56
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-15-scope"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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_idwas set (calendar_unsupported), which made the flagship case — completing today's occurrence of a recurring task — impossible.docs/technical/calendar.md#recurrence-and-mutationanddocs/technical/domain-model.md#identity-and-relationshipsrequire the caller to choosescope: seriesorscope: 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 isNeedsClarification),reject_split_series(aRECURRENCE-ID;RANGE=THISANDFUTURE/THISANDPRIORseries is refused, never applied to the whole series and never approximated to one occurrence), and the occurrence writers:recurrence_id_editandmaterialized_timesrender the occurrence's own identity and rebasedDTSTART/DTEND/DUE, preserving the series' value type and timezone (VALUE=DATEstays a date, aTZIDseries 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-dayDTENDis never invented back into aVTODOthat has none.src/calendar/preservation.rs: the patcher is split intolocate/target_span/rewrite_body/assemble, and gainscomponent_existsplusadd_detached_component, which appends a clone of the master carrying the occurrence'sRECURRENCE-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_occurrenceenumerates 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_collectionandcomponent_in_bodyfall back to that enumeration when no stored component carries the id, soGET /v1/items/{id}and every mutation agree on what an occurrence id names.src/api/mod.rs:ScopedTarget/resolve_scoped_targetresolve which component a mutation acts on and refuse the unrepresentable combinations before anything is planned or dispatched;clarification_responsereturns 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.seriesrewrites the master's own lines;occurrencerewrites 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 conditionalPUTunder the caller's exact quotedIf-Match(unchangedrequired_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:scopeonTaskPatch,EventPatchandLifecycleInput, bound into the idempotency fingerprint so the same key with a different scope is anidempotency_conflict. An unknown scope value is still rejected by serde before any dispatch.src/cli.rs:--scope series|occurrenceontask update/complete/reopen/cancelandevent update/cancel; the value is carried into the request body (a contradicting bodyscopeis 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 warningsandcargo fmt --checkare clean.New suite
tests/scoped_recurrence_tests.rs(28 tests, mock Radicale over TCP, asserting on the exact bytesPUTand on the CLI exit codes):needs_clarificationwithoptions: [series, occurrence], zeroPUTs, 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) is400 invalid_inputbefore the backend is contacted;scope: occurrenceon a master id is refused;VTODOwithRECURRENCE-ID;VALUE=DATE:20300403,STATUS:COMPLETED,COMPLETED,PERCENT-COMPLETE:100and materializedDTSTART/DUE, while the master component is byte-identical, stillSTATUS:NEEDS-ACTION, and still the only component carrying the rule; reopening a stored occurrence patches only that component and clears its completion;TZID=Europe/Parisseries keeps its wall clock andTZID; an existing override is patched in place;RANGE=THISANDFUTUREis422 calendar_unsupportedfor both scopes and for a lifecycle transition, with no write and no operation;*/multi-tag ETags are400with zero requests, a replayed occurrence mutation does not re-dispatch, the same key with a different scope is a409 idempotency_conflict, and a412is reported asetag_conflictwith the operation id and is never retried;calendar_unsupported, exit 0 on success with the scope carried in the body, exit 2 locally for an unknown--scopevalue, 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) andan_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
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 test284 passed / 0 failed includingscoped_recurrence_tests(28), clippy 0 errors, fmt clean.The implementation is right on the parts that are easy to get wrong:
add_detached_componentbuilds the clone withPropertyEdit::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_timeswrites only the properties the master declares, with a comment explaining that the parser derives an exclusive all-dayDTENDand writing that back would invent a property the series never had. It preserves the series' value type and zone (date staysVALUE=DATE,TZIDseries keeps wall clock andTZID, floating stays floating, UTC stays UTC) — which is exactly criterion 4, and it is covered byan_occurrence_is_materialized_in_the_series_own_value_type_and_zoneacross all four shapes.reject_split_seriesrefusesRANGE=THISANDFUTURE/THISANDPRIORrather 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_scopekeeps 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, zerooperationsrows and an untouched resource.Change needed: the branch is two commits behind and has not been verified against current
main.origin/feat/issue-15-scopeis based on9ffd57d, butmainis now at12fcf8d— #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 onmainhas never run against this change. It also makes the PR diff read as if it deletes 6,580 lines of sibling modules.Please:
git fetch origin main && git rebase origin/mainin.worktrees/issue-15-scope. Keep both sides on any registration conflict insrc/lib.rs,src/api/mod.rsorsrc/domain/mod.rs— the jev and hindsight modules and routes must survive.src/hindsight/*andsrc/jev/*(non-zero count) and that the PR diff no longer shows them as deletions.jev_fixtures,jev_client_testsandhindsight_client_testsalongside your new suite — plusclippy -D warningsandfmt --check.Keep everything else exactly as it is. Do not merge.
3588723912toa09d201207Coordinator re-review — APPROVED
Verified
a09d201myself, including the integration concern that prompted the round trip:origin/feat/issue-15-scopenow sits on12fcf8d(currentmain).src/hindsight/andsrc/jev/;git diff --stat origin/main...origin/feat/issue-15-scopelists only this slice's 14 files, so the earlier apparent 6,580-line deletion of sibling modules is gone.cargo teston the rebased tree: 374 passed / 0 failed across 18 integration binaries — which now includesjev_fixtures,jev_client_testsandhindsight_client_testsalongsidescoped_recurrence_tests(28),update_tests,lifecycle_testsandrecurrence_tests.cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.That closes the only outstanding item; the code itself needed no change. Recording the substance for the merge message: mandatory
--scopeon recurring items resolving toneeds_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 dropsRRULE/RDATE/EXDATEfrom the clone and materializes the occurrence's own times in the series' value type and zone, series-scope edits leaving sibling overrides byte-intact, andRANGE=THISANDFUTURE/THISANDPRIORrefused rather than approximated — all under one conditionalPUTwith the caller's exact quotedIf-Match.Merging. This is the last slice of Phase 2.
feat(calendar): scoped recurring mutations for series and occurrences (#15)to feat(calendar): scoped recurring mutations for series and occurrences