ADR-0041: Native Google Calendar provider — OAuth, provider mapping, syncToken delta¶
Status: Accepted (2026-06-14)
Status pass (2026-10-18): the code this record describes is in the private domain tree, not in this repository. the code it names is in the private domain tree
Context: Calendar agent C1 (second slice; builds on ADR-0040's provider abstraction). Companions: ADR-0003 (Keychain creds), ADR-0016 (email_accounts cross-cutting registry / calendar.db), ADR-0040 (provider abstraction, ICS first). Mirrors the Gmail connector shape (gmail_oauth.py, history-delta sync).
Context¶
ADR-0040 shipped the provider abstraction with one provider (ICS, write-only,
no live feed). To read a user's real calendar we need a native Google
Calendar provider — per the standing user direction: ICS (Apple) first,
then native Google; no external MCP. It must plug into the existing
CalendarProvider interface with zero changes to the store, service,
skills, or CLI semantics, and reuse the established Google connector
machinery (OAuth installed-app flow, Keychain credential storage, the
email_accounts registry) rather than reinventing it.
Decision¶
-
Reuse the Gmail connector shape for OAuth.
iris_harness.identity.gcalendar_oauthmirrorsgmail_oauthexactly — installed-app flow, tokens in Keychain underKEYRING_PROVIDER = "gcalendar", accounts registered in the ADR-0016EmailAccountStorewithprovider = "gcalendar". Scope is read-only (calendar.readonly) for C1; the authorized address is resolved fromcalendarList().get(calendarId="primary")["id"](the primary calendar's id is the account email).login/status/logout/load_credentialsparallel Gmail one-for-one — one mental model for every Google connector. -
Pure mapping functions, separate from I/O.
google_event_to_calendar_event(ev, *, calendar_id)andcalendar_event_to_google_body(event)are pure dict↔CalendarEventtransforms (timed vs all-daydateTime/datenodes, attendee emails,cancelledstatus preserved, deterministicid = "google:{calendar_id}:{gid}"). TheGoogleCalendarProvider(aCalendarProvider) is a thin shell overevents().{list,insert,patch, delete}that delegates all shape logic to those functions — so mapping is unit-testable with no network and no fakes. -
Incremental delta via
syncToken, stored per (account, calendar).google_sync.sync_calendaris the cold-start-vs-delta analogue of the Gmail history sweep. First run (no token) pulls a bounded window (timeMin = now − 30d,singleEvents=True); subsequent runs send the storedsyncTokenwithshowDeleted=True. A newcalendar_synctable (account_id, calendar_id, sync_token, PK(account_id, calendar_id)) holds the cursor.nextSyncTokenis persisted only after the last page. -
410 → automatic full resync. An expired
syncTokenreturns HTTP 410; the sweep clears the token, resets counters, setsfell_back_to_full=True, and re-runs from the bounded window in the same call. Idempotent: re-running upserts the same rows. -
Cancellations tombstone locally. Mapped events with
status == "cancelled"(surfaced byshowDeleted) callstore.delete_eventrather than upsert, so a deletion on Google removes the local row. All other events upsert by the deterministic id. -
Read-only into the authoritative store. Sync never writes to Google; it only populates
calendar.db, which remains the single read model (ADR-0040).create/update/delete_eventon the provider exist for the C2 write path but are not exercised by sync. -
CLI surface.
iris calendar sync [--account --calendar --db-path]iterates connectedgcalendaraccounts (soft-fails per account), andiris auth gcalendar login|status|logoutmirror the Gmail auth commands.
Alternatives considered¶
- Poll
events.listby time window every run (no syncToken). Rejected — re-downloads the whole window each sync, can't observe deletions cheaply, and doesn't scale.syncTokenis Google's first-class delta primitive. - A separate
gcalendar_accountstable / credential store. Rejected — reuse the ADR-0016email_accountsregistry + Keychain (providercolumn already discriminates). One identity substrate for all Google connectors. - Request read/write scope now. Rejected for C1 — least privilege; the write scope arrives with the C2 write-governance path (ADR-0008), not the read sweep.
- Parse ICS exported from Google. Rejected — lossy, no delta, no
deletions; the API +
syncTokenis the live feed ADR-0040 deferred.
Consequences¶
Positive: - Google plugs into the existing abstraction with no store/service/skill/CLI semantic changes — the abstraction paid off. - Mapping is pure and fully unit-tested offline (timed/all-day/attendees/ cancelled/no-id, paginated list, initial→delta, cancellation-tombstone, 410→full-resync) — 9 new tests, no network. - One Google connector mental model (OAuth/Keychain/registry identical to Gmail); least-privilege read-only scope.
Negative / deferred:
- Read-only: local edits don't propagate to Google yet (C2 write path).
- One calendar per sync call (primary default); multi-calendar fan-out is
a caller loop, not built in.
- Bounded 30-day cold-start window is a default, not yet configurable per
account.
- mypy emits no-untyped-call/no-any-return on the google-auth surface —
same accepted pattern as the merged gmail_oauth.py; mypy is not CI-gated.
Related ADRs¶
- ADR-0003 (Keychain creds), ADR-0016 (
email_accountsregistry /calendar.db), ADR-0040 (provider abstraction, ICS first), ADR-0008 (calendar write R2/R3 — C2), ADR-0029 (brief slots — C2).