ADR-0042: Calendar write governance as a boundary module (not a governor-policy extension)¶
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 C2 (read/write skill split + write governance + brief + event reminders). Builds on ADR-0040 (provider abstraction) + ADR-0041 (Google provider). Implements the intent of ADR-0008 (R2/R3 calendar write split) and reuses ADR-0005 (reminders soft-migration).
Context¶
ADR-0008 specced the calendar write-approval split as an extension to the governor policy schema:
- route: calendar/write
approval: { mode: auto-with-undo-window, window_seconds: 600,
escalate_to: prompt, escalate_when: { attendees_present: true } }
But the IRIS governor as built (iris_harness.governor) is a coarse boolean route
guard: GovernorRoutePolicy has requires_approval: bool and the engine
checks metadata.approval_granted — there is no conditional escalate_when,
no approval modes, and no undo-window primitive anywhere. The governor is a
shared kernel (coding/git, coding/mcp, llm/cloud/ …); teaching it
conditional escalation + a stateful undo window to satisfy one domain would
add cross-cutting surface and blast radius to code every route depends on.
(Other domains — finance/, task/* — don't even register their routes in
policy.yaml; tool execution doesn't hard-depend on the governor.)
C2 needs the behaviour ADR-0008 describes — R2 auto-with-undo for private/solo events, R3 prompt for attendee events — without destabilising the shared governor.
Decision¶
-
Implement the R2/R3 split + undo as a calendar boundary module (
iris_harness.calendar.write_governance.CalendarWriteGovernor), not as a governor-policy feature. Deterministic, pure-domain, fully unit-testable offline — the same boundary-module pattern Finance used for its consequence logic. The governor remains the coarsecalendar/writegate (unchanged); the fine-grained tier decision lives next to the domain that understands events. -
Tier from event shape (
CalendarEvent.has_attendees).classify_write(has_attendees)→R3for attendee events,R2otherwise. Updates/deletes consider the prior event's attendees too (so editing or removing an attendee event is R3). -
R2 = perform-now + undo token. The write happens immediately and a row is logged in a new
calendar_undotable (token, action, event_id, snapshot_json, created_at, expires_at, undone_at) with a 600s window.undo(token)reverts within the window: create→delete, update→restore prior snapshot, delete→re-create prior snapshot (via the newCalendarService.restore_event, which re-materialises under the original id). Single-use; expired/used/unknown tokens are refused. -
R3 = require explicit approval. Without
approved=Truethe write is not performed; the caller gets a message to confirm. The CLI surfaces this as--yes; thecalendar-composetool asapprove=true. -
Read/write skill split.
calendar-eventsbecomes read-only (list_events,list_today) and stays default-on. The write surface moves to a new opt-incalendar-composeskill (default_enabled: false) —create_event/update_event/delete_event, each routedcalendar/writeand governed byCalendarWriteGovernor. Calendar writes are consequential, so the write skill is off until enabled. -
Event reminders reuse
notifications.ReminderStorewithtarget_kind="event"(ADR-0005) — no new reminder primitive.schedule_event_reminder/schedule_reminder_beforeare thin helpers; the channel layer's title lookup is extended to read the event summary fromcalendar.dbso a fired event-reminder reads "Event:". -
Brief "today's events" section via a
list_todayread tool + atodays_eventsslot in the morning-briefing manifest.
Alternatives considered¶
- Extend
GovernorRoutePolicywithescalate_when+ approval modes + an undo store (literal ADR-0008). Rejected for C2 — high blast radius on a shared kernel for one domain's needs; the governor's job is coarse allow/deny/approve, not domain consequence modelling. Can still be done later if multiple domains want conditional escalation; the boundary module doesn't preclude it. - Keep
create_eventin the default-oncalendar-eventsskill. Rejected — that bypasses governance (a default-on tool would write attendee events with no R3 gate). Writes belong behind the opt-in governed skill. - A new reminder primitive for events. Rejected —
ReminderStorealready modelstarget_kind="event"(ADR-0005); reuse, don't reinvent. - In-memory undo window. Rejected — undo must survive across CLI
invocations / processes, so it's persisted in
calendar.db.
Consequences¶
Positive:
- ADR-0008's R2/R3 behaviour ships, fully tested offline (11 governance
tests), with zero change to the shared governor.
- Clean read/write separation; writes are opt-in and uniformly governed
(CLI + skill share CalendarWriteGovernor).
- Undo is durable and single-use; event reminders reuse existing delivery.
Negative / deferred:
- The governor policy.yaml is not the source of truth for the calendar
tier decision — it lives in the boundary module. Documented here so it's
discoverable.
- R3 "prompt" is realised as "requires explicit approval" (--yes /
approve=true), not yet an interactive channel round-trip via the
ApprovalQueue — a later integration.
- Google write-back is still deferred: the provider is read-only (C1) and
pushing writes needs the calendar.events scope (re-auth). C2 writes
target the ICS provider + store.
Related ADRs¶
- ADR-0008 (R2/R3 split — intent realised here), ADR-0005 (reminders migration), ADR-0040 (provider abstraction), ADR-0041 (Google provider), ADR-0029 (brief slots).