CalendarEventNotification/changes rejects state returned by /get for empty notification collection

Issue Description

For an account with no CalendarEventNotification history, CalendarEventNotification/get returns a state that a subsequent CalendarEventNotification/changes call cannot use.

In the observed case, /get returned:

saa

which Stalwart encodes as State::Exact(0).

Calling /changes with that same value as sinceState returns:

{
  "type": "cannotCalculateChanges",
  "description": "The server cannot calculate the changes between the old and new states."
}

A full resync returns the same state again, so the failure repeats on the next incremental sync.

The current implementation appears to cause this because the empty notification changelog is converted to change id 0, while the generic /changes path later treats a non-initial state with no changelog entry as invalid.

Source trace:

  • crates/groupware/src/cache/calcard.rs, build_scheduling_resources: get_last_change_id(...) returns None when there is no notification changelog and unwrap_or_default() converts this to change id 0.
  • crates/jmap/src/calendar_event_notification/get.rs: the response state is taken from cache.get_state(false), turning change id 0 into State::Exact(0) (saa).
  • crates/jmap/src/changes/get.rs: the current-state short circuit for an exact sinceState handles Calendar, AddressBook, and Email, but not CalendarEventNotification.
  • The subsequent changelog lookup for Query::Since(0) is empty, leaving from_change_id == 0. The generic /changes code then treats this non-initial state as invalid and returns cannotCalculateChanges.

This means the empty notification collection is represented by a non-initial state which Stalwart itself issues but cannot subsequently process while the collection remains unchanged.

Expected Behavior

CalendarEventNotification/changes should accept a recently returned state from CalendarEventNotification/get.

If no changes have occurred, I would expect an empty successful response with the same old and new state.

RFC 8620 permits cannotCalculateChanges, but recommends that servers support calculating changes from state strings recently issued to clients.

References:

  • RFC 8620, Section 5.2
  • draft-ietf-jmap-calendars-26, Section 7.2

Actual Behavior

CalendarEventNotification/get returns saa, but an immediate CalendarEventNotification/changes using sinceState: "saa" returns cannotCalculateChanges.

Repeating a full /query + /get reconciliation returns the same unusable state.

Reproduction Steps

  1. Use an account with no CalendarEventNotification history.
  2. Call CalendarEventNotification/get.
  3. Save the returned state.
  4. Without making any changes, call CalendarEventNotification/changes using that value as sinceState.
  5. Observe cannotCalculateChanges.
  6. Call /get again and observe that the same state is returned.

Relevant Log Output

Nothing from Stalwart.

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This confirms the reported trace precisely: the State::Exact(change_id) branch only builds a last_state short-circuit for Calendar, AddressBook, and Email, mapping to None for anything else (including CalendarEventNotification), so it falls through to Query::Since(0), and since the changelog is empty from_change_id stays 0, tripping the later changelog.from_change_id == 0 && request.since_state != State::Initial check that raises cannotCalculateChanges. This is a confirmed bug in the code as it stands, exactly as ripdog diagnosed. Since this is a bug report on a real code defect rather than a “how do I configure X” question, I should acknowledge it plainly rather than try to answer as documentation.

I have enough to give a grounded reply confirming the analysis.

Your analysis is correct and matches the current code. In crates/jmap/src/changes/get.rs, the State::Exact(change_id) branch only computes a last_state short-circuit for SyncCollection::Calendar, AddressBook, and Email (falling through to None for CalendarEventNotification and ShareNotification). Without that short-circuit, the request falls through to Query::Since(change_id), and when the changelog is empty changelog.from_change_id stays 0. The subsequent check:

if (changelog.is_truncated || changelog.from_change_id == 0)
    && request.since_state != State::Initial
{
    return Err(trc::JmapEvent::CannotCalculateChanges...)
}

then treats the empty-collection state as invalid, even though it was the exact state Stalwart just issued via CalendarEventNotification/get. So the account gets stuck issuing a state it cannot subsequently accept, exactly as you traced through calcard.rs and changes/get.rs.

This is a genuine defect in the current implementation, not a configuration issue, so there is nothing to adjust on your end to work around it beyond forcing a state reset (e.g., ignoring the reported state and always doing a full resync when hitting cannotCalculateChanges). A human maintainer will need to add CalendarEventNotification (and likely ShareNotification) to that last_state match arm, or otherwise avoid returning a non-initial state for an empty changelog.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.18 (yet to be released).