# Kade v0.4 — Calendar Integration Approach Memo

**Author:** Forge
**Audience:** Kade (driver), Riv (consumer), Jimmie (decision)
**Date:** 2026-05-24
**Status:** Phase 1 design memo. Full API contract drafts after this is approved.

Owner decision locked: full read+write on `jimmie@j2bookkeeping.com` Google Calendar (the main calendar everything else feeds from).

---

## TL;DR — recommended path

| Decision point | Recommendation | Why |
|---|---|---|
| Auth | **User OAuth 2.0** (not service account) | Single-user scope; no Workspace admin friction; one-time click |
| Token storage | `.env.local` (refresh token) + ephemeral access token in memory | Matches [[reference-atlas-secrets-location]] pattern; refresh tokens are long-lived |
| Sync model | **Hybrid: cached mirror + live write-through** | Fast reads for "Today," accurate writes via GCal-first |
| Change detection | **Polling with `syncToken`** (every 60s) — not push | Atlas is localhost; no public webhook without new infra (Cloudflare Tunnel etc.) |
| Quota concern | None at expected volume | Free tier is 1M req/day; we'd use < 0.2% |

If Jimmie wants push notifications instead of polling (sub-minute latency on outside-edits), that's a v0.5 add — requires Cloudflare Tunnel or similar to expose `https://atlas.jimmie.tld/api/kade/calendar/webhook`.

---

## 1) OAuth flow

### Service account vs. user OAuth — recommending user OAuth

| | Service Account | User OAuth |
|---|---|---|
| Setup | Create SA in GCP, enable domain-wide delegation in Workspace admin, grant `https://www.googleapis.com/auth/calendar` scope | Create OAuth client in GCP, Jimmie clicks consent screen once |
| Friction | Workspace-admin-level setup; per-user impersonation scopes | Single click; refresh token persists |
| Best for | Multi-user app on a Workspace domain | Single-user automation against one calendar |
| Risk | If misconfigured, can impersonate anyone in the domain | Scoped to the consenting user only |

**Recommend user OAuth.** This is one user, one calendar. Service-account DWD is overkill and gives broader access than needed.

### One-time grant UX for Jimmie

1. Forge ships `GET /api/kade/calendar/oauth/start` — redirects to Google consent
2. Jimmie clicks the link (Kade DM or Atlas UI button), sees Google's "Allow Kade to read+write your calendar" screen, clicks Allow
3. Google redirects to `GET /api/kade/calendar/oauth/callback?code=...`
4. Server exchanges code for `{ access_token, refresh_token }`, writes `refresh_token` to `.env.local`, persists `kade_calendar_account` row with metadata (email, expires_in, granted_scopes)
5. Bot DMs Jimmie: "Calendar connected ✅"

**One click total.** If the refresh token ever invalidates (rare — only on revoke, password change, or 6mo of zero-use), Kade DMs Jimmie with the re-auth link.

### Scopes requested

Minimum viable:
- `https://www.googleapis.com/auth/calendar.events` (read+write events on owned calendars)

We do NOT need:
- `calendar.readonly` (we want write)
- `calendar` (full incl. ACL — over-scoped)
- `calendar.settings.readonly` (not needed yet)

### Token storage

- `GOOGLE_CALENDAR_CLIENT_ID` and `GOOGLE_CALENDAR_CLIENT_SECRET` → `.env.local`
- `GOOGLE_CALENDAR_REFRESH_TOKEN` → `.env.local` (written by oauth/callback endpoint)
- Access tokens cached in-memory (per-process) with TTL = `expires_in - 60s`
- On 401 from Google API, refresh once, retry once; if second 401, surface as `calendar_reauth_needed` to bot

### Refresh-token expiry

Google's policy:
- Refresh token issued via consent screen → never expires unless explicitly revoked
- 6-month no-use window also revokes (immaterial for us — Kade polls every minute)
- App in "Testing" mode caps tokens at 7 days. **Verification required to escape.** This is the gotcha. For internal use Jimmie can leave the app in Testing mode and accept weekly re-auth, OR publish as Internal app via Workspace admin (one-time toggle, no Google review) which gives unlimited token life.

**Recommend: publish as "Internal" via Google Workspace admin console.** Jimmie owns the workspace; this is a one-toggle change. No app verification, no 7-day token horizon.

---

## 2) Data model — hybrid cache

### Tables (proposed; full SQL lands with v0.4 contract)

```sql
-- One row per connected Google account (forward-looking; v0.4 has exactly one)
CREATE TABLE kade_calendar_accounts (
  id              bigint PRIMARY KEY,
  google_email    text NOT NULL UNIQUE,
  calendar_id     text NOT NULL DEFAULT 'primary',
  sync_token      text,                            -- Google's incremental-sync cursor
  last_full_sync_at  timestamptz,
  last_incremental_sync_at  timestamptz,
  scopes          text[] NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now()
);

-- Cached mirror of GCal events. Source of truth = GCal; this is read-through cache + write-buffer.
CREATE TABLE kade_calendar_events (
  id              bigint PRIMARY KEY,
  account_id      bigint NOT NULL REFERENCES kade_calendar_accounts(id) ON DELETE CASCADE,
  google_event_id text NOT NULL,                   -- GCal's id
  ical_uid        text,                            -- for cross-app dedupe
  summary         text NOT NULL,
  description     text,
  location        text,
  start_at        timestamptz NOT NULL,
  end_at          timestamptz NOT NULL,
  all_day         boolean NOT NULL DEFAULT false,
  status          text NOT NULL,                   -- 'confirmed' | 'tentative' | 'cancelled'
  attendees       jsonb,                           -- [{email, response_status}, ...]
  organizer_email text,
  visibility      text,                            -- 'default'|'private'|'public'
  recurrence      text[],                          -- RRULE/RDATE strings from GCal
  recurring_event_id text,                         -- parent of an instance
  created_by_kade boolean NOT NULL DEFAULT false,  -- we wrote it
  task_id         bigint REFERENCES kade_tasks(id) ON DELETE SET NULL,  -- linked task
  gcal_etag       text,                            -- for conditional updates
  fetched_at      timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT kade_calendar_events_uniq UNIQUE (account_id, google_event_id)
);

CREATE INDEX kade_calendar_events_window_idx
  ON kade_calendar_events(account_id, start_at, end_at)
  WHERE status != 'cancelled';
CREATE INDEX kade_calendar_events_task_idx
  ON kade_calendar_events(task_id) WHERE task_id IS NOT NULL;
```

### Why hybrid (cached + live)

| | Live-only | Cached-only | Hybrid (recommended) |
|---|---|---|---|
| Today read latency | 200-800ms (GCal RTT) | 5ms (local) | 5ms (local) |
| Conflict detection | Slow loop of API calls | Fast SQL | Fast SQL |
| Staleness risk | None | High (out-of-band edits invisible) | Bounded by sync interval (≤60s) |
| Write reliability | Must be online | Could write to cache, sync later (complex) | GCal first, then cache update — simple |
| Quota use | High (every read = call) | Tiny | Low (sync only) |

Cached reads + live writes is the cleanest balance. GCal stays source-of-truth per [[feedback-lean-on-source-files]]; cache exists to make Kade fast and offline-tolerant for reads.

---

## 3) Sync direction & change detection

### Direction

- **Atlas → GCal:** every Kade write hits GCal first (`POST/PATCH/DELETE events.{insert,patch,delete}`). On 2xx, server updates the local cache row with the returned event + etag. On non-2xx, surface as `calendar_write_failed` — do NOT write to cache (avoids drift).
- **GCal → Atlas:** poller pulls `events.list?syncToken=...` every 60s. Updates/inserts/deletes cache rows accordingly. On `410 Gone` (sync token expired), trigger full resync.

### Why polling, not push (for v0.4)

Push notifications via `events.watch` push to a webhook URL. Atlas runs at `http://localhost:3000` — no public hostname. To accept push:
1. Expose Atlas via Cloudflare Tunnel (or ngrok) — new infra, new credential to rotate
2. Configure `events.watch` to point at the tunnel URL
3. Handle the push verification + renewal (channels expire after 7 days)

Polling sidesteps all of that. 60s latency is fine for Kade — Jimmie isn't switching meetings every 30s. Sub-minute push is a v0.5 nice-to-have if Jimmie ever wants it.

### Cadence

| Trigger | Action |
|---|---|
| Bot startup | Incremental sync (`syncToken` if present, else full) |
| Every 60s (scheduler) | Incremental sync |
| Every 24h (scheduler) | Full sync as fallback / token-rotation safety |
| On any write from Atlas | Refresh affected event row from GCal response |
| On 410 from sync | Full resync, store new syncToken |

---

## 4) Quota planning

Google Calendar API free quotas (per-project, per-user):
- 1,000,000 requests per day per project
- 600 requests per 100 seconds per user

Expected Kade usage:
- 1 incremental sync / 60s = 1,440 / day
- ~20-50 reads/writes per day from Jimmie's bot interactions
- 1 full sync / 24h = 1 / day
- Total: ~1,500 calls/day. **0.15% of quota.** Tons of headroom.

Even with future agents poking calendar (Echo for email-to-event, etc.) we're nowhere near limits.

---

## 5) Endpoint sketch (Phase 2 will fully spec)

Server endpoints (Forge builds):
- `GET  /api/kade/calendar/oauth/start` — OAuth kickoff
- `GET  /api/kade/calendar/oauth/callback` — OAuth landing
- `GET  /api/kade/calendar/account` — connection status
- `GET  /api/kade/calendar/today` — today's events (cache hit, <10ms)
- `GET  /api/kade/calendar/events?start=...&end=...` — windowed list (cache)
- `GET  /api/kade/calendar/conflicts?start=...&end=...` — overlap detection (cache)
- `POST /api/kade/calendar/events` — create event (GCal first, cache after)
- `PATCH /api/kade/calendar/events/:id` — update event
- `DELETE /api/kade/calendar/events/:id` — cancel event
- `POST /api/kade/calendar/sync` — manual full resync (admin/debug)

Bot commands (Riv builds against the contract):
- "what's on my calendar [today/tomorrow/Friday]"
- "block [time] for [thing]" → POST event
- "move [event] to [new time]" → PATCH
- "cancel [event]" → DELETE
- "what conflicts do I have [this week]"
- "what's my next meeting"

DND linkage (v0.3 dependency): the create-event endpoint checks `kade_dnd_windows` and warns/blocks if the requested time falls inside an active DND window. Wire after v0.3 ships.

Task linkage: `POST /api/kade/calendar/events` accepts optional `task_id` to link a calendar block to a task. `GET /api/kade/tasks/:id` (from v0.2 contract) returns `calendar_event_ids[]` if any.

---

## What Jimmie needs to do (one-time, on his own time)

1. Open `console.cloud.google.com` → create a GCP project "Kade Calendar" (or use existing PKA project)
2. Enable Google Calendar API
3. Configure OAuth consent screen — User Type = **Internal** (requires Workspace admin, which Jimmie is)
4. Create OAuth 2.0 Client ID — type Web application, redirect URI = `http://localhost:3000/api/kade/calendar/oauth/callback`
5. DM Riv the Client ID and Client Secret (he'll drop them into `.env.local`)
6. After Forge ships v0.4 endpoints: Kade DMs Jimmie the auth link, Jimmie clicks once → connected ✅

Forge will write a runbook for steps 1-5 alongside the contract draft.

---

## Open questions (Kade — decide before Phase 2)

These are decisions for Kade (with Jimmie's input if needed) before Forge drafts the full contract:

1. **DND-violation behavior on writes.** When Kade asks to book inside DND, do we (a) refuse, (b) warn-and-confirm, or (c) silently allow + log? Recommend (b).
2. **Task↔event linkage default.** When Jimmie types "block 2-4pm for Acadian quote" — does Kade auto-create a task too? Recommend: only if an existing task with matching keywords is found; surface that link in the DM. Don't proliferate tasks.
3. **Multi-calendar future.** Today = primary calendar only. Schema supports multiple via `calendar_id`. Confirm we lock to "primary" for v0.4 and revisit if/when Jimmie adds a 2nd calendar.

---

## Sequencing recap

- Phase 1 (this memo): approval ← we are here
- Phase 2: Forge drafts full API contract, OAuth runbook, migration 009 → Kade + Riv ack
- Phase 3: Forge ships endpoints + OAuth flow. Jimmie does the one-time GCP setup + clicks consent.
- Phase 4: Riv builds bot commands against the contract.
- Phase 5: Burn-in alongside v0.2 task flow.

Realistically v0.4 lands after v0.2 + v0.3 burn in. This memo just unblocks the design conversation.

— Forge
