ADR-0075: Calendar as the single write authority (events, invites, appointments, reminders)¶
Status: Accepted (2026-06-23)
Status pass (2026-10-18): the code this record describes is in the private domain tree, not in this repository. no CalendarService or CalendarWriteGovernor in this repository
Relates to: ADR-0008 (calendar write governance, R2/R3 attendee split), ADR-0016 (per-domain DBs), issue 0025 (calendar agent read path)
Context¶
The calendar agent can now read events (issue 0025). The user wants it to also create events, meeting invites, appointments, and reminders — and to push those out to Google and Apple calendars, not just sync inward.
Investigating the current surface (5 parallel subsystem deep-dives) found that the write machinery is far more built than expected, but ownership is fragmented across two parallel time-management subsystems that do not share a store:
iris_harness.calendar— the modern system.CalendarService.add_event/update_event/ delete_eventalready writes through to both a provider and the SQLite store (data/calendar.db,service.py:84,88).CalendarWriteGovernor(ADR-0008 R2/R3-
600s undo) exists and is invoked by the CLI and skill tools — but not auto-wired into the service, so
service.add_eventcan be called unguarded (the task/bill mirror deliberately does). Providers satisfy aCalendarProviderProtocol (create/update/delete/list_events).GoogleCalendarProviderimplements full CRUD over the Google API (google_provider.py:147-172) with complete body mapping; it is simply not enabled (OAuth defaults tocalendar.readonly, and the provider isn't registered inCalendarService.default()). -
iris_harness.reminders— the legacy system. "remind me to X at 5pm" is caught by a deterministic chat intercept (_handle_reminder_creation_turn) that writes to a separate markdown store (~/.iris/reminders/reminder.md+ companion.ics), with its own delivery heartbeat (reminder_tick) and delivery ledger. It never touchesdata/calendar.db(the chat intercept even passescall_calendar_mcp=False).
The planner is read-only — it aggregates calendar + tasks + bills + followups and
never writes. Tasks are their own store (TaskStore, data/tasks.db).
The conflict¶
- Split brain. A reminder lands in
reminder.md(gets delivered but is invisible to the calendar agent and planner). A calendar-agent-created event lands incalendar.db(shows on the calendar/planner butreminder_ticknever fires it). Reminders that notify but aren't on the calendar; events on the calendar that never notify. - Double front-door. The reminder intercept fires before intent routing and grabs "remind me…"; a calendar create flow would grab "schedule/add a meeting…". Overlapping phrasings could double-create.
- Governance. Meeting invites = attendee events = R3 (require approval, ADR-0008). Reminders today bypass governance entirely.
Decision¶
The calendar subsystem (data/calendar.db via CalendarService) is the single
write authority for all time-bound items: events, meetings/invites, appointments,
and reminders. A reminder is simply a no-attendee event carrying an alarm
(alarm_minutes_before > 0, source="reminder"); the planner shows it for free.
Role separation (no merging beyond this):
- Calendar owns writes — one governed front door (CalendarService wrapped by
CalendarWriteGovernor); writes fan out to the store and the active provider(s).
- Planner owns the aggregated read view — unchanged; reminders appear automatically
once they live in calendar.db.
- Tasks owns to-dos — unchanged; calendar_mirror keeps projecting due tasks onto
the calendar for viewing.
Resolutions to the conflict:
- Split brain → unify on calendar.db. Reminders become alarm-bearing events
(source="reminder"). A one-time importer migrates reminder.md; the chat reminder
intercept is rewired to write through CalendarService; reminder_tick delivery is
repointed to read due reminder-events from calendar.db (delivery idempotency via the
existing notifications store).
- Double front-door → keep the deterministic intercept, change its sink. The robust
regex/lexical reminder parser stays (local-model tool-calling is unreliable — same
rationale as issue 0025/0024); it just persists via CalendarService instead of
reminders.py. A parallel deterministic create intercept handles
"schedule/add a meeting|appointment".
- Governance → route writes through the governor. Attendee events (invites) hit the
R3 approval gate; private events/reminders are R2 (auto + undo window).
Apple/iCloud write-back: EventKit via pyobjc with an osascript fallback,
darwin-gated. Rationale: privacy-first (no stored credentials — uses the OS Calendar
authorization), local (writes to Calendar.app, which syncs to iCloud), least-credential
(no app-specific password or separate OAuth). Rejected: CalDAV-to-iCloud (needs an
app-specific password + credential plumbing) and ICS-import (no real write-back).
Consequences¶
- One source of truth for everything time-bound; the planner and calendar agent see reminders and events alike.
reminders.pywrite functions become thin shims/deprecated;reminder.mdis migrated and retired. A transition window runs both readers until the importer has run.- Enabling Google write requires a scope upgrade + user re-auth
(
iris auth gcalendar loginwithcalendar.events). Apple write requires macOS + the EventKit framework (optional dependency; degrades toosascript). - Invites become governed (R3); reminders/private events stay R2 with the undo window.
Rollout (dependency-ordered slices, one PR each)¶
- Slice 1 — foundation (no deps):
CalendarEvent.alarm_minutes_before+"reminder"inEventSource; additivecalendar.dbmigration (alarm_minutes_beforecolumn via an idempotent_add_column_if_missing). This PR. - Slice 2 — governed front door: make
CalendarServiceroute writes throughCalendarWriteGovernor(opt-in injection; mirror keeps its documented bypass). - Slice 3 — Google write-back: add
calendar.eventsscope, registerGoogleCalendarProviderinCalendarService.default()with account context, re-auth guide. (parallelizable after slice 1) - Slice 4 — Apple provider: new
apple_provider.py(EventKit + osascript fallback), darwin-gated registration. (parallelizable after slice 1 — new file) - Slice 5 — reminders unification: (shipped) the chat reminder intercept now
creates a
calendar.dbevent (source="reminder") and schedules delivery via the existing notificationsReminderStoreevent bridge (schedule_event_reminder) — fired by the already-wirednotification_reminder_tick, which enriches the message fromcalendar.db. So no new delivery path was needed; the legacy markdownreminder_tickis left in place (now reads an empty store) and is deprecated. A one-time importer (calendar/reminder_migration.py,iris calendar import-reminders) migrates activereminder.mdrows (idempotent viasource_id). (depends on slice 1) - Slice 6 — chat create flow: (shipped) a deterministic
_handle_meeting_creation_turnintercept (in bothchatandchat_stream, after the reminder intercept): parses "schedule/add a meeting|appointment|event … at
Timezone handling (post-merge fixes #271, #272)¶
Live-verifying the create flow exposed a day-boundary bug: events are stored as aware UTC, but the read surfaces computed their day windows in UTC, so once UTC passed local midnight "any meeting tomorrow?" / "what's on today" selected the wrong day, and times displayed in UTC. The rule, applied to both read surfaces:
- Store in UTC (unchanged) — uniform
+00:00keeps the SQLite ISO-string range comparison correct. - Windows are the user's local calendar day: derive the timezone from the
caller's
now(defaultdatetime.now().astimezone()), build the day's[start, end]in that zone, then convert to UTC instants for the store query. - Display converts each event/item back to local time.
- Tests stay deterministic by passing a UTC-aware
now(→tz=UTCreproduces the old bounds); a pinned non-UTC zone test asserts the local-day + local-time behavior.
Applied in calendar/digest.py (parse_window, build_calendar_digest, _fmt_event)
and planner/plan.py (build_daily_plan and the item builders). The Planner shared
the same latent UTC-day assumption, so it was fixed in the same way for parity.
Planner target day (post-merge enhancement #273)¶
With the calendar now writable, the Planner — previously a today-only synthesizer —
accepts a target day. parse_target_day(query, now) recognizes today/tonight,
tomorrow, day-after-tomorrow, an ISO date, and weekday names (next occurrence);
build_daily_plan(..., target_date=...) plans that local day (events in the window,
tasks due that day, bills as-of that day). Overdue tasks are surfaced only when
planning the current day — a future plan shows just that day's items, no carryover.
Amendment (2026-09-13, OSS plan M5.7 track A)¶
The single write authority stands; its home moved. CalendarService and
CalendarWriteGovernor are now iris_harness.plugins_builtin.calendar.service
/ .write_governance, with the concrete providers, their sync and Google OAuth, under the
owner's rule that core provides mechanisms and anything that talks to an external app is
a plugin. The chat paths that write (the reminder_creation and meeting_creation
intercepts and the calendar_event confirmation executor) moved with it. What the core
keeps is the governance mechanism: the confirmation intercept, the continuation the
question rides on, and the executor registry it dispatches by kind. The store the
service writes stays core, because the planner and the calendar_lookup tool read it.