The Hidden Dependency Graph in RFC 5545
I kept finding the same bug.
An all-day recurring event has an exception. The EXDATE gets written as a
bare date (20250429), but DTSTART is a datetime
(20250429T090000Z). Types don't match. The exception silently
stops working. Someone misses a meeting. RFC 5545 section 3.8.5.1 says the
EXDATE value type "MUST be the same" as DTSTART. One paragraph in a
170-page spec.
Nextcloud had this bug. Fossify had it. FullCalendar, sabre/vobject, ical4j, python-recurring-ical-events. Seven projects across eight languages. That's not seven independent mistakes. That's a spec that buries critical rules in prose nobody reads end to end.
The EXDATE type rule is one of roughly 20 inter-property dependencies scattered across RFC 5545 and its companion RFCs. Change DTSTART and you might invalidate your RRULE expansion, break EXDATE type matching, shift VALARM trigger offsets, orphan RECURRENCE-ID references in exception instances. None of these relationships are formalized anywhere. They live in prose, spread across six documents, and every implementer rediscovers them the hard way.
So I extracted them into a dependency graph: 27 VEVENT properties, 20 edges, 6 edge types. YAML and JSON, CC BY 4.0.
Edge types
| Edge Type | Meaning | Example |
|---|---|---|
depends_on |
Changing A makes B's interpretation stale | RRULE depends on DTSTART for expansion |
type_consistency |
A and B must use the same value type | EXDATE and DTSTART: both DATE or both DATE-TIME |
requires |
A without B is a protocol violation | ATTENDEE requires ORGANIZER |
mutually_exclusive_with |
A and B cannot both be present | DTEND and DURATION |
derived_from |
B was computed from A at creation time | RECURRENCE-ID derived from the original DTSTART |
computes_with |
A and B are used together in computation | DTSTART + DURATION gives the implied end |
Each edge also has a strength. must = protocol error if
violated. should = suboptimal but conformant.
advisory and informational are for warnings
and annotations.
Why three-way merge doesn't solve this
Field-level three-way merge is a solved problem for independent fields. Calendar properties are not independent fields.
Local changes DTSTART from DATE-TIME to DATE. Remote adds an EXDATE with
a DATE-TIME value. A field-level merge keeps both. Now you have an invalid
event: EXDATE type no longer matches DTSTART. A merge engine that doesn't
know about the type_consistency edge between these two
properties will happily produce garbage.
I classified all 27 properties into five merge safety categories:
| Category | Count | Properties | Rule |
|---|---|---|---|
| safe | 11 | SUMMARY, DESCRIPTION, LOCATION, URL, GEO, PRIORITY, CATEGORIES, COLOR, CLASS, TRANSP, STATUS | No cross-property dependencies. Merge independently. |
| dependent | 7 | DTSTART, DTEND, DURATION, RRULE, EXDATE, RDATE, VALARM | Mergeable if the dependency graph validates. |
| scheduling | 3 | ATTENDEE, ORGANIZER, REQUEST-STATUS | Triggers iTIP (RFC 6638). Can't auto-merge. Falls back to dependent on servers without scheduling support. |
| immutable | 3 | UID, CREATED, RECURRENCE-ID | Set once. Never changed. |
| always_update | 3 | SEQUENCE, DTSTAMP, LAST-MODIFIED | Auto-set on every edit. |
Two clients change SUMMARY and LOCATION respectively? Both safe, keep both. One changes DTSTART while the other adds an EXDATE? Both dependent. Check the graph: if the types still match after the DTSTART change, the merge holds. If not, flag a conflict.
The SEQUENCE trap
This one is my favorite.
SEQUENCE: increment it when you make a "significant" change. Sounds straightforward. RFC 5546 defines "significant" as changes to start time, end time, duration, recurrence, or organizer. Most implementations skip that nuance and just increment on every save.
Outlook CalDAV Sync issue #463 shows what happens next. Two clients syncing the same event. Each save bumps SEQUENCE, which updates DTSTAMP, which triggers a re-upload, which the other client sees as a change, which bumps SEQUENCE again. It hit 743 before someone noticed.
The correct merge rule has three cases:
- Only one side changed significant properties: keep that side's SEQUENCE.
- Both sides changed significant properties:
max(local, remote) + 1. - Neither did:
max(local, remote).
But you need to know which properties count as "significant" to apply this. Which means you need the dependency graph.
25 bugs, 17 projects
I cataloged 25 bugs across 17 projects in 8 languages (JavaScript, Python, C, Java, PHP, Kotlin, C#, Vala) where the root cause traces to a violated property dependency. Two rules account for more than half: RRULE depends on DTSTART (6 bugs, oldest from 2013) and EXDATE type must match DTSTART (7 bugs).
These aren't hobby projects. Nextcloud, Home Assistant, Thunderbird (via ical.js), GNOME Calendar (via libical), Cyrus IMAP. They keep showing up because every library rediscovers each rule by reading RFC prose independently. A shared, machine-readable list of these rules would have caught most of them at parse time.
Try it
import yaml
with open('rfc5545-deps.yaml') as f:
graph = yaml.safe_load(f)
# What does RRULE depend on?
for edge in graph['properties']['RRULE']['depends_on']:
print(f"RRULE -> {edge['target']} ({edge['strength']})")
# RRULE -> DTSTART (must)
# Can SUMMARY be independently merged?
print(graph['properties']['SUMMARY']['merge_safety'])
# "safe"
# What about EXDATE?
print(graph['properties']['EXDATE']['merge_safety'])
# "dependent"
There's a JSON equivalent for languages where YAML parsing is unreliable.
Both files are structurally identical. Fields prefixed with _
are non-normative annotations.
Scope
This is a specification, not a library. It tells you which properties depend on which and whether concurrent edits can be safely combined. It doesn't implement the merge itself.
VEVENT only. VTODO and VJOURNAL have different dependency structures (the Tasks draft, for instance, drops the requirement that VTODO DURATION needs DTSTART). A reference parser and test suite are planned. The graph was manually extracted from six RFCs and cross-checked against the text, but hasn't had a second independent extractor verify it. If you spot an error or a missing edge, open an issue.
Links
- GitHub repository (YAML, JSON, full paper)
- Zenodo preprint (DOI: 10.5281/zenodo.19299690, CC BY 4.0)
- Full paper with evidence catalog, related work, and design notes