March 28, 2026 · Ravi Kashyap

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 TypeMeaningExample
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:

CategoryCountPropertiesRule
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:

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.

Full bug catalog with links to every issue: RFC5545_DEPENDENCY_GRAPH.md.

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.