Repository navigation
caldav: move or cancel an event as the account that holds it - #56
Conversation
UpdateEvent and CancelEvent loaded the user's current destination connection and sent its Basic credentials to the stored event URL. A host can connect several CalDAV accounts, so once the destination moved from an account on server A to one on server B, rescheduling or cancelling an older booking sent B's username and app password to server A. A refusal left the event in place and the reconciler retried, resending them every sweep for a cancelled booking and until the end time for a moved one. A 404 was taken as "already gone". Update and cancel now resolve the owning connection from what the booking stored. An account can hold the event only if the event URL lies inside one of its collections (the bound calendar_id or a saved connection_calendars id): same scheme, host and port, path under it at a segment boundary. Among those, the one whose collection is the recorded calendar id wins, else the most specific collection. No match or a tie sends nothing and returns an error for the caller to log. With no CalDAV connection at all it returns nil, as the Provider contract says, and logs a warning. A single account that is still the destination sends exactly the requests it did before. The Service routed update and cancel by the current destination's provider, so after a move to Google or Microsoft the CalDAV event URL went to that provider, which cannot find it, and the event on the CalDAV server was never touched. calendar.EventRecognizer lets a provider claim its own ids; CalDAV claims absolute http(s) URLs, which Google and Microsoft ids never are, and everything else still routes by destination. Tests use two httptest servers that record the credentials on every request: the cross-account move and cancel, an empty calendar id (as reassign passes), an event in a picked sub-calendar, a destination moved to another provider, a disconnected owner, and the ambiguous and no-match cases sending zero requests. Restoring loadConn(ctx, userID, -1, 1) fails three of them with B's password seen on server A. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shockalotti
left a comment
There was a problem hiding this comment.
The credential-safety core is right and I want this merged: eventConn resolves the account that holds the event instead of the current destination, pickEventOwner is a pure, testable function with segment-boundary containment and tie refusal, the single-connection cursor discipline is observed, and the EventRecognizer interface keeps Google/Microsoft routing untouched with a stated reason. It closes the password-to-the-wrong-server leak.
What blocks me is the second half of the problem statement — the reconciler retry storm. I checked internal/handler/calendar_reconcile.go: both reconcileReschedules (line ~98) and reconcileCancellations (line ~135) treat ANY UpdateEvent/CancelEvent error as 'log, leave the flag/id in place, retry next sweep'. Your new fail-closed errors (errNoEventOwner, errManyEventOwner) therefore loop forever: every sweep, an error log, no resolution. The credential re-send is gone (eventConn errors before anything is sent), but the storm shape remains as log spam over a permanently stranded event.
Ask: make the terminal errors terminal. When eventConn reports no account holds the event (or several could), the reconciler should clear needs_sync / null the orphaned event id with a warn log, not retry — nothing about a future sweep can change which accounts hold that URL. A tie (errManyEventOwner) arguably deserves to stay flagged for a human, but even then 'retry the identical pick every sweep' resolves nothing; at minimum it should log at most once or carry a distinct dead-end marker. Happy with any shape that stops the infinite loop — the current one just moves it.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes Owning-account CalDAV credentials and event-id routing for update/cancel, plus the tests and docs that pin the old destination leak.
- Owning account, not destination —
eventConn/pickEventOwnersend Basic auth only for a collection that contains the event URL (origin + segment-boundary path); no match or a tie sends nothing. - Route by event id —
calendar.EventRecognizersends absolutehttp(s)ids to CalDAV even after the destination moves to Google or Microsoft. - Tests — two-server
httptestfixtures assert which password hits which host, including empty calendar id, picked sub-calendars, provider move, disconnect, and ambiguous/unknown owner. - Changelog / ARCHITECTURE — security disclosure plus the CalDAV exception to the known account-move limit; reassign comment matches the empty-calendar-id path.
Grok | 𝕏
CHANGELOG conflict only: kept both [Unreleased] entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eventConn refuses to send anything when the stored ids do not pick out exactly one CalDAV account: no account holds the event URL, two tie, or the id is not a URL at all. The reconciler treated that refusal like any other failure, leaving needs_sync set (reschedules) or the event id in place (cancellations) and trying again next sweep. Nothing is sent now, but a retry re-reads the same connections and refuses the same way, so the loop only moved: an error line every two minutes until the booking ended, and forever for a cancellation, whose sweep has no date bound. The three refusals now match calendar.ErrEventUnreachable. On it the reconciler logs one warning and lets go: it clears needs_sync for a reschedule (the event keeps its old time and its id) and clears the id for a cancellation (the event stays on its calendar). The cancel warning names the event, since clearing the id drops the only record of it, and logs it redacted because a CalDAV id is a URL that can carry userinfo. A tie is treated the same. needs_sync is surfaced nowhere a person would see it, so leaving it set would reach nobody but the log, and only a change to the host's connections could break the tie. Every other error stays retryable, including a server that is down or refuses the stored password, since reconnecting or waiting fixes those. Tests: the reconciler, with a provider that claims the event, over two sweeps: an unreachable refusal is called once, clears the flag or id and warns exactly once; a transient error is called twice and keeps both. The warning does not log a password embedded in the event URL. On the CalDAV side, all seven no-owner and tie cases now assert the sentinel, and a refused password and an unreachable server assert that they do not match it. Restoring the old reconciler branch fails both reconciler tests; removing the sentinel match fails 14 CalDAV assertions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes Terminal handling of CalDAV owner-refusal in the reconciler, since the prior pullfrog review on 7a14a5b.
- Sentinel for a refused send —
errEventNotURL,errNoEventOwner, anderrManyEventOwnernow matchcalendar.ErrEventUnreachablevia a typedIs, so a stored-state dead end is distinct from a failed request. - Reconciler stops retrying — On that sentinel it warns once and clears
needs_sync(reschedule; event keeps its old time) or the event id (cancel; id logged throughurl.Redacted()). Other errors still retry. - Tests — Two-sweep handler tests pin give-up versus 503, cancel-log redaction of URL userinfo, and CalDAV 401 / unreachable server staying retryable.
Grok | 𝕏
|
Done in 3c7e662, on top of a merge of The three refusals (no account holds the event, a tie, and an id that is not a URL) now match a new I treated a tie the same as no owner rather than keeping it flagged. Tests drive the reconciler over two sweeps with a provider that claims the event. An unreachable refusal is called once, clears the flag or id, and warns exactly once. A transient error is called twice and keeps both. On the CalDAV side, all seven no-owner and tie cases now assert the sentinel, and a refused password and an unreachable server assert that they do not match it. Putting back the old reconciler branch fails both reconciler tests, and removing the sentinel match fails 14 CalDAV assertions. CHANGELOG and ARCHITECTURE §10 say the same. |
Merge-resolution fix for #56: the branch predates the 6-arg saveConnection (caldav_home_url). Empty home URL preserves the PR's tested semantics; homeURL is only read by ListCalendars.

The problem
caldav.Client.UpdateEventandCancelEventcalledloadConn(ctx, userID, -1, 1), which is the user's current destination connection, and sent its Basic credentials to the stored event URL. A host can connect several CalDAV accounts (saveConnectionkeys by account), so:UpdateEventandCancelEventtreat that as "already gone", so Calnode clears the flag or id and the event silently stays.Serviceroutes update/cancel by the destination's provider, so the CalDAV event URL went to Google/Graph, which cannot find it. The event on the CalDAV server was never moved or deleted.Servicefinds no destination and returns nil; the event stays on the CalDAV server (there are no credentials left to remove it with).booking_hosts.external_calendar_id(00055) was meant to cover a change of destination, but the CalDAV provider ignored it. It also can't help with a move between accounts on its own, and ARCHITECTURE §10 lists that as a known limit.The fix
Owning account, not destination (
internal/caldav/events.go).eventConnloads every CalDAV connection the user has, with its boundcalendar_idand savedconnection_calendarsids, thenpickEventOwnerpicks one account:reassign.go, which passes"".Providercontract's "zero values when the user has no matching connection".Routing by event, not destination (
internal/calendar/calendar.go). There's a new optionalcalendar.EventRecognizerinterface.Service.UpdateEvent/CancelEventsend an event to the provider that recognizes its id, and otherwise to the destination as before. CalDAV recognizes absolute http(s) URLs. Google ids (base32hex) and Microsoft ids are never URLs, so their routing is unchanged.A single CalDAV account that is still the destination sends exactly the requests it did before: GET, then PUT with
If-Match, then DELETE, all with the same credentials. A test pins that sequence, with and without a recorded calendar id.Tests
internal/caldav/events_test.goruns twohttptestCalDAV servers. Each one accepts only its own account and records the credentials on every request.connection_calendars): resolved by recorded id and by URL.TestPickEventOwnertable: scheme, default port, host case, trailing slash, nesting, and the two tie kinds.On
main, every integration test above fails except the single-account test and the non-URL-id case (the table tests use new functions, so they were not run there) (B's password shows up on server A; the CalDAV URL goes to the other provider). RestoringloadConn(ctx, userID, -1, 1)on top of the fix fails five tests, and in three of them server A receivespw-b. Reverting just the routing change fails the two provider-move tests.gofmt -l .is clean,go vet ./...andgo build ./...pass, andgo test ./...is green.Notes for review
EventRecognizerdoesn't help with the reverse case, a Google or Microsoft event after the destination moves to CalDAV. That still routes by destination and fails without sending anything, as before. Fixing it would mean recording the provider per event.🤖 Generated with Claude Code