JMAP CalendarEvent/set silently ignores ifInState: stale writes are applied instead of returning stateMismatch

Issue Description

CalendarEvent/set accepts an ifInState argument, parses it, and then never compares it to the current state. A client that supplies a superseded state gets its write applied, where RFC 8620 §5.3 requires the method to be aborted with a stateMismatch error:

ifInState: (type: String|null) This is a state string as returned by the Foo/get method. If supplied, the string must match the current state, otherwise the method will be aborted and a stateMismatch error returned.

Email/set implements this correctly. So does CalendarEvent/copy. Only CalendarEvent/set is missing the call, which is what makes this look like an oversight at the call site rather than a deliberate choice.

Impact: JMAP calendar writes have no lost-update protection at all. This bites harder for calendars than it may appear, because a JMAP CalendarEvent carries no per-object revision: no ETag, no changeKey. ifInState is therefore the only concurrency guard the protocol offers a calendar client. With it unenforced, two clients editing the same event silently produce last-writer-wins, and the loser’s edit disappears with no error and no way to detect it. The same server protects the same event correctly over CalDAV (If-Match gives a 412), so the guarantee a client gets depends on which protocol it reaches the event through.

Root cause

The check already exists as a shared helper and is simply not called.

The helper, crates/jmap/src/changes/state.rs:31:

fn assert_state(&self, is_container: bool, if_in_state: &Option<State>) -> trc::Result<State> {
    let old_state: State = self.get_state(is_container);
    if let Some(if_in_state) = if_in_state
        && &old_state != if_in_state
    {
        return Err(trc::JmapEvent::StateMismatch.into_err());
    }
    Ok(old_state)
}

Correct call site, crates/jmap/src/email/set.rs:80-81:

let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
    .with_state(cache.assert_state(false, &request.if_in_state)?);

Missing call site, crates/jmap/src/calendar_event/set.rs:99:

let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;

request.if_in_state is never read anywhere in that 999-line file. The only mention of state is response.new_state = State::Exact(change_id).into(); on line 521, which reports the new state without ever having checked the precondition.

The same type’s copy sibling gets it right, crates/jmap/src/calendar_event/copy.rs:75:

let cache = self
    .fetch_dav_resources(access_token.account_id(), account_id, SyncCollection::Calendar)
    .await?;
let old_state = cache.assert_state(false, &request.if_in_state)?;

That is the same DavResources cache, fetched the same way with SyncCollection::Calendar, and impl JmapCacheState for DavResources sits at changes/state.rs:80. So the helper is already in scope and already available on exactly the value that calendar_event/set.rs is holding.

Suggested fix

A one-liner, mechanically identical to email/set.rs. false for is_container matches copy.rs, since a CalendarEvent is a member rather than a container:

--- a/crates/jmap/src/calendar_event/set.rs
+++ b/crates/jmap/src/calendar_event/set.rs
@@ -99 +99,2 @@
-        let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?;
+        let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)?
+            .with_state(cache.assert_state(false, &request.if_in_state)?);

Happy to send this as a PR if that is easier for you.

Possibly the same omission elsewhere

Auditing every /set in crates/jmap/src for a call to assert_state, the split is striking. The original mail types enforce it and the newer DAV-backed types do not, while their own copy counterparts do:

Method enforces ifInState?
Email/set, Email/import, Mailbox/set, Sieve/set, VacationResponse/set yes
Email/copy, Contact/copy, File/copy, CalendarEvent/copy yes
CalendarEvent/set no
Calendar/set, Contact/set, AddressBook/set, File/set no
CalendarEventNotification/set, ParticipantIdentity/set, ShareNotification/set no

I have only reproduced CalendarEvent/set. The rest of that table is read from the source rather than tested, so please treat it as a lead and not a finding. Glad to split those into separate topics if you would prefer.

Expected Behavior

A CalendarEvent/set whose ifInState does not match the current state is rejected, per RFC 8620 §5.3, and nothing is written:

["error", {
  "type": "stateMismatch",
  "description": "An \"ifInState\" argument was supplied, but it does not match the current state."
}, "0"]

Actual Behavior

The stale write is applied, and a fresh newState is returned as though nothing were wrong:

["CalendarEvent/set", {
  "accountId": "c",
  "newState": "sn2",
  "updated": { "v": null }
}, "0"]

A follow-up CalendarEvent/get confirms the clobbering write landed.

Reproduction Steps

Stock Docker server, account c, calendar b. A throwaway event is created and destroyed, so nothing else is touched.

1. Read the current state.

// CalendarEvent/get { "accountId": "c", "ids": [] }
→ "state": "snu"     // call this S1

2. Create an event, which moves the state past S1.

// CalendarEvent/set { "accountId": "c", "create": { "p": { ... } } }
→ { "created": { "p": { "id": "v" } }, "newState": "sny" }     // S1 is now stale

3. Update using the stale ifInState. This must be rejected.

["CalendarEvent/set", {
  "accountId": "c",
  "ifInState": "snu",
  "update": { "v": { "title": "CLOBBERED" } }
}, "0"]

The write is accepted:

["CalendarEvent/set", {
  "accountId": "c",
  "newState": "sn2",
  "updated": { "v": null }
}, "0"]

4. Confirm the stale write landed.

// CalendarEvent/get { "ids": ["v"], "properties": ["title"] }
→ [{ "id": "v", "title": "CLOBBERED" }]

5. Control: the identical pattern on Email/set, which behaves correctly.

// Email/set { "accountId": "c", "ifInState": "<stale>", "update": { "<id>": { "subject": "CLOBBERED" } } }
→ ["error", {
     "type": "stateMismatch",
     "description": "An \"ifInState\" argument was supplied, but it does not match the current state."
   }, "0"]

Same request shape, same shared SetRequest type, opposite behaviour.

6. The argument is definitely parsed, it is just never compared. This is not an unknown field being dropped: a wrong-typed value is rejected at deserialization.

$ curl ... -d '{...,"methodCalls":[["CalendarEvent/set",{"accountId":"c","ifInState":12345,"update":{}},"0"]]}'
HTTP 400
{"type":"urn:ietf:params:jmap:error:notRequest","status":400, ...}

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 is a well-documented bug report with source-level analysis. I don’t have anything to add or verify beyond what’s already fetched and cited correctly here, so I won’t speculate on the fix’s correctness or the extent of the wider table, a human maintainer needs to confirm and merge this.

This isn’t covered in the current docs. A community member or staff member will follow up.

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.14 (not yet released).