JMAP Calendars: synthetic occurrence ids change meaning after a recurrence override

Issue Description

Version: Stalwart 0.16.20 (nixpkgs 0968519e14f7aa7d3e9b389682bd74d2b51c8ce8,
pkgs.stalwart_0_16, store path gmjyhdprvlljzhhn21myj1dzsmxsq313-stalwart-0.16.20), RocksDB
backend, ordinary user account. Also observed on a PostgreSQL-backed deployment of the
same version.

Context: this sits on top of the synthetic-id write support from
stalwartlabs/stalwart#2925 (closed 2026-08-28); the writes themselves work as described
there. Reads and writes on synthetic ids are correct; what changes is which occurrence an
id names.

Spec: draft-ietf-jmap-calendars-28, section 5.11: with expandRecurrences: true “a separate
id will be returned for each instance of a recurring event that matches the query. This
synthetic id is opaque to the client, but uniquely identifies the base event id +
recurrence id within the account, allowing the server to resolve these for “/get” and
“/set” operations.”

Expected Behavior

A synthetic id keeps identifying the same base event + recurrence id for as long as that instance
exists, so that a client can expand once and then update or destroy several instances without
re-querying between writes.

Actual Behavior

What happens: after one update on a synthetic id, the ids of every later instance of the
same series resolve to a different recurrence id than before. The id appears to encode a
position in the current expansion (rule instances that are not overridden, then overrides)
rather than base event + recurrence id.

Reproduction Steps

Reproduction

Weekly series, five occurrences, created with:

[“CalendarEvent/set”, {“accountId”: “b”, “create”: {“c0”: {
@type”: “Event”, “uid”: “urn:uuid:380ccc1a-d2cd-4fb6-88f7-c34fe81988ee”,
“calendarIds”: {“b”: true}, “title”: “repro bfe4da2e”,
“start”: “2027-03-01T09:00:00”, “duration”: “PT15M”, “timeZone”: “Europe/Berlin”,
“showWithoutTime”: false,
“recurrenceRule”: {“@type”: “RecurrenceRule”, “frequency”: “weekly”, “count”: 5}}}}, “s”]

→ created as id f.

Expansion (the same query is repeated below; only the uid filter and the window are set):

[“CalendarEvent/query”, {“accountId”: “b”,
“filter”: {“after”: “2027-03-01T00:00:00”, “before”: “2027-04-10T00:00:00”, “uid”:
“urn:uuid:380ccc1a-…”},
“sort”: [{“property”: “start”, “isAscending”: true}],
“expandRecurrences”: true, “timeZone”: “Europe/Berlin”}, “q”]

  1. Before any write: ids: [“eaaaaaf”, “iaaaaaf”, “maaaaaf”, “qaaaaaf”, “uaaaaaf”] → recurrence ids
    03-01, 03-08, 03-15, 03-22, 03-29 respectively (confirmed via /get with recurrenceId).

  2. Update the second occurrence by its synthetic id:
    [“CalendarEvent/set”, {“accountId”: “b”, “update”: {“iaaaaaf”: {“start”: “2027-03-08T10:00:00”}}},
    “u”]
    → {“updated”: {“iaaaaaf”: null}}; the base event now has recurrenceOverrides: {“2027-03-08T09:00:00”:
    {“start”: “2027-03-08T10:00:00”, “duration”: “PT15M”, “updated”: “…”}}. So far correct.

  3. Same expansion again: ids: [“eaaaaaf”, “uaaaaaf”, “iaaaaaf”, “maaaaaf”, “qaaaaaf”] — and /get on
    them:

id recurrenceId before recurrenceId after
eaaaaaf 2027-03-01 2027-03-01
iaaaaaf 2027-03-08 2027-03-15
maaaaaf 2027-03-15 2027-03-22
qaaaaaf 2027-03-22 2027-03-29
uaaaaaf 2027-03-29 2027-03-08 (the overridden one)

Direct /get on [“iaaaaaf”, “maaaaaf”] after the update returns the 03-15 and 03-22 instances.

  1. A client that still holds maaaaaf from step 1 and destroys it now removes 03-22, not 03-15:
    [“CalendarEvent/set”, {“accountId”: “b”, “destroy”: [“maaaaaf”]}, “d”]
    → {“destroyed”: [“maaaaaf”]}, base event recurrenceOverrides now also has “2027-03-22T09:00:00”:
    {“excluded”: true}. Expansion afterwards: [“eaaaaaf”, “qaaaaaf”, “iaaaaaf”, “maaaaaf”] = 03-01, 03-08
    (override), 03-15, 03-29.

Stalwart Version

v0.16.x

Installation Method

NixOS

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

OIDC

Additional Context

This issue reporting ux is bad.

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

I checked the current source for the synthetic id implementation and it confirms what you’re seeing.

In crates/jmap/src/calendar_event/query.rs, when expandRecurrences: true, the server calls calendar_event.data.expand(default_tz, time_range) for each event and assigns the synthetic id from expansion.expansion_id (see CalendarSyntheticId::new(expansion_id, document_id) in crates/jmap/src/calendar_event/mod.rs, which packs the two into the Id’s prefix). The expansion_id is not a stored, stable value tied to a recurrenceId — it’s assigned positionally by expand() at query time, over whatever set of instances the expansion currently produces.

Once an occurrence is overridden, expand()'s output ordering changes (your reproduction shows overridden instances effectively being appended/reordered relative to plain rule-generated instances), so the same expansion_id position now points at a different occurrence. That matches your table exactly: after overriding 03-08, iaaaaaf (expansion_id backing “second instance”) starts resolving to 03-15 instead.

So this is confirmed in the current code, not a doc gap: synthetic ids currently encode a position in the expansion result set rather than a durable base-event+recurrenceId mapping, which is a mismatch with the spec language you quoted. This is a real behavioral bug in the implementation rather than a documentation question, so I can’t resolve it here — flagging it as reproducible against source for the human maintainers to follow up on.

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.21 (yet to be released).