┌──────────────── Calendar Bridge (one container, outbound HTTPS only) ────────────────┐
Google │ GoogleObserver ──┐ ┌── Executor ──► Google API │
Calendar ◄─┼─ (events.list + │ observed state ┌─ Reconciler ─┤ (intent journal, │
API │ nextSyncToken) ├──► google_events ──────┤ link → plan │ conditional writes, │
│ │ icloud_resources │ → guard → │ read-back verify) │
iCloud │ ICloudObserver ──┘ (SQLite, WAL) └─ execute ────┴── Executor ──► CalDAV │
CalDAV ◄──┼─ (sync-collection, items / baselines / write_intents / conflicts / audit_log │
│ multiget, verify) status.json · status calendar · alerts · backups │
└────────────────────────────────────────────────────────────────────────────────────┘
Source layout (src/calendar_bridge):
| Module | Role |
|---|---|
model/canonical.py |
Provider-neutral event model and field groups |
model/merge.py |
Three-way change detection and merge/conflict decisions |
model/recurrence.py, timeutil.py, text.py |
Occurrence identity, RRULE normalization, zones, mirror info block |
providers/google/ |
OAuth, REST client, Google ⇄ canonical conversion |
providers/icloud/ |
CalDAV client (discovery via caldav, strict data plane), iCalendar ⇄ canonical |
sync/observe.py |
Change detection with deletion-evidence rules |
sync/reconcile.py, executor.py, recovery.py, resolve.py, initial.py |
The engine |
db/ |
Schema, migrations, repository, backups |
status/ |
Status contract, Google status calendar, alerts |
daemon.py, cli.py |
Service loop and operator commands |
One item is one synchronization unit: a single event, a recurring series (master + modified
occurrences + cancelled occurrences), or a detached occurrence. Both providers are converted into
CanonicalEvent; changes are compared and applied per field group:
| Group | Contents |
|---|---|
summary, description, location, status, transparency, visibility, alarms, url |
one field each |
timing |
start, end, RRULE, RDATE together (any of them shifts occurrences) |
exdates |
cancelled occurrences — EXDATE in iCalendar, cancelled instances in Google |
override:<key> |
one modified occurrence, keyed by its original start (YYYYMMDD or UTC YYYYMMDDTHHMMSSZ), never by its current start |
people, conference |
informational: guest list and call links, only ever rendered as text on the mirror |
Occurrences are identified by their original start, which is also the suffix Google uses for
instance ids (<masterId>_<key>), so occurrence identity survives moves.
For every item the database stores, separately for Google and for iCloud, the canonical form of that provider’s copy as it was when both sides were last verified in agreement. A side changed when its current form differs from its own baseline. Comparing each provider only with itself makes provider normalizations (re-ordered RRULE parts, rewritten zones, extra Apple properties) invisible, and makes the bridge’s own writes come back as “no change” — no echo loops, no reliance on timestamps.
Decision rules (model/merge.py), all-or-nothing per item:
exdates changed on both sides (timing untouched) → union of both sides’ additions/removals;timing on one side + occurrence edits on the other → conflict (occurrence identity depends on timing);people/conference only flow from the owning provider to the mirror;authoritative policy: edits of shared fields made on the mirror
become guest_managed conflicts; personal fields (alarms, free/busy, visibility) still sync;“Converged” is judged through projections: the canonical form a value would have after being written to the other provider (e.g. HTML descriptions become text in iCloud; floating times are pinned to the default zone in Google; Google keeps at most 5 reminders ≤ 4 weeks). A difference that is only due to such a known limitation is never reported as a change.
Google — events.list with identical parameters for full and incremental reads
(singleEvents=false, showDeleted=true), all pages, then nextSyncToken. HTTP 410 → full
re-read. Recurring masters and their exception instances are stored separately and grouped by
recurringEventId.
iCloud — cheap getctag/sync-token check; sync-collection (RFC 6578) with truncation
handling; changed resources fetched with calendar-multiget; valid-sync-token errors or missing
support fall back to a complete listing (sync-collection with an empty token, else
PROPFIND Depth: 1) compared by ETag.
Durability — observed changes and the new checkpoint are committed in one transaction, after all pages were read. A crash before that commit simply re-reads the same changes (recording is idempotent).
Deletion evidence — Google: an explicit cancelled record, or 404/410 on a direct read of
an event missing from a complete listing. iCloud: a direct request for that exact resource
returning 404/410. Anything else (errors, timeouts, truncated listings) parks the resource as
unverified_missing, re-checked next cycle, never acted upon.
For each remote mutation (sync/executor.py):
If-None-Match: * (412 on retry → adopt); updates and deletions
carry If-Match: <observed ETag>;Recovery (sync/recovery.py) runs before every reconciliation: an open create intent is
looked up by its exact identity — found: adopted, with the recorded source version as the other
side’s baseline, minus anything the interrupted create had not yet applied (so the next cycle
completes it); not found: released and re-done (respecting any retry backoff). Open update/delete intents are abandoned and the
item is re-planned from freshly observed state (an applied write is recognized as converged).
Every write also lands in audit_log. Google writes use sendUpdates=none and never include
attendees, organizer or conference data; CalDAV writes send Schedule-Reply: F and mirrors never
contain ORGANIZER/ATTENDEE.
Plans are computed for all dirty items before anything runs. If the number of deletions exceeds
max(mass_delete_count, mass_delete_ratio × live items), deletions for that mapping are held
(items stay pending), an alert is raised, and everything non-destructive proceeds (held items
do not count against the per-cycle budget). A release (safety release) records the ids of the
held deletions the user reviewed and lets exactly those run, within one hour; any other deletion is
checked again. A hold lifts by itself once no deletion is waiting. A global hold follows a failed
database integrity check.
reconcile reads both calendars completely and pairs existing events — by identity (UID,
iCalUID, bridge markers), then by exact content when exactly one candidate exists on each side —
before anything is copied. Ambiguous candidates are never merged. Pairs whose content differs
become conflicts; differences only in personal fields are tolerated (each side keeps its own).
The plan is printed with a verdict; --apply records the pairs and activates the mapping; copying
then happens through the normal write path.
SQLite in WAL mode with synchronous=FULL and foreign keys. Main tables: calendars,
mappings (partial unique indexes enforce one-to-one), checkpoints, google_events and
icloud_resources (last observed state), items (identities, per-side baselines and
fingerprints, state), write_intents, conflicts, audit_log, alerts, sync_runs,
credentials (encrypted), meta. Migrations are numbered SQL files applied at start-up after an
automatic backup.
calendar-bridge status --json and <data>/status.json expose calendar-bridge.status/v1
(fields are only added within v1):
{
"schema": "calendar-bridge.status/v1",
"overall": "ok | degraded | error | not_configured",
"daemon": {"heartbeat_at": "...", "last_ok_cycle_at": "...", "sync_interval_seconds": 60},
"google": {"state": "ok | invalid | revoked | missing", "account": "..."},
"icloud": {"state": "...", "account": "..."},
"mappings": [{
"id": 1, "name": "main", "state": "active",
"last_google_read_at": "...", "last_icloud_read_at": "...",
"last_google_write_verified_at": "...", "last_icloud_write_verified_at": "...",
"lag_seconds": 12.3, "items": {"synced": 412, "pending": 0, "conflicted": 0, "...": 0},
"open_write_intents": 0, "oldest_open_write_intent_at": null, "destructive_writes": "allowed | suspended",
"pending": [{"item_id": 7, "google_event_id": "...", "waiting_for": "icloud", "title": "...", "start": "..."}],
"recently_verified_in_icloud": [{"item_id": 3, "google_event_id": "...", "verified_in_icloud_at": "...", "title": "..."}]
}],
"conflicts": {"open": 0, "items": []},
"failures": {"retryable": 0, "permanent": 0},
"alerts": []
}
Other systems should rely on this contract (or on the CLI) rather than on database tables.