# CalDAV multi-calendar sync — design

**Task:** t-paliad-212
**Inventor:** leibniz (2026-05-19)
**Branch:** mai/leibniz/inventor-caldav-multi
**Status:** READY FOR REVIEW — m's decisions on the §8 open questions captured in the addendum below (2026-05-19).

---

## §0 — One-paragraph summary

Paliad's CalDAV sync today is a single-target push: every user has one
`paliad.user_caldav_config` row, and every Appointment they can see gets
PUT into that one calendar. m wants users to pick their own organization —
one cal with everything, one cal per project (or per client / litigation /
patent / case), or any hybrid. This design splits the model in two:
**credentials stay per user** (one CalDAV server, one auth blob) and
**bindings become first-class rows** (a join table `paliad.user_calendar_bindings`
that points an Appointment-filter scope at a specific `calendar_path`).
Push/pull state migrates from scalar `appointments.caldav_uid`/`caldav_etag`
columns to a per-(appointment, binding) join table
`paliad.appointment_caldav_targets`, so the same Appointment can live in
N external calendars at once. The 60-second per-user sync goroutine survives
unchanged in shape; inside it the inner loop iterates bindings instead of
hard-coding `cfg.CalendarPath`. Sliced for safe rollout: Slice 1 introduces
the new tables behind a backfill that auto-creates one binding per
existing config row (zero behaviour change); Slice 2 ships the
binding-picker UI; Slice 3 wires scope-aware filtering (one cal per project).
Bidirectional sync stays exactly as it works today (last-write-wins on ETag,
Paliad-owned UIDs only) — multi-calendar does not change the conflict
model.

---

## §1 — What's already built (verified live, 2026-05-19)

Verified against the codebase, not the project's CLAUDE.md.

- **Schema** — `paliad.user_caldav_config` is one row per user with
  `(user_id PK, url, username, password_encrypted bytea, calendar_path,
  enabled, last_sync_at, last_sync_error, created_at, updated_at)`. The
  scalar `calendar_path` is the only handle on which external calendar
  receives events. Per direct `information_schema` query.
- **Appointment binding** — `paliad.appointments` carries scalar
  `caldav_uid text` and `caldav_etag text` (nullable). Set once after a
  successful PUT via `AppointmentService.SetCalDAVMeta`. This is the
  single-target assumption baked into the row itself.
- **Sync engine** — `internal/services/caldav_service.go:298–502`. One
  goroutine per enabled user, 60s ticker, `runSyncOnce` → `syncOnce` →
  `pushAll` (`AppointmentService.AllForUser` × `cli.PutEvent`) +
  `pullAll` (`cli.PropfindCalendar` → `cli.GetEvent` → reconcile by UID).
  `AllForUser` returns *every* personal-or-visible-project appointment
  for the user; today they all funnel into the single `calendar_path`.
- **UID convention** — `paliad-appointment-<uuid>@paliad.de`
  (`caldav_ical.go:31–34`). Foreign UIDs are intentionally skipped on
  pull (`caldav_service.go:436–442`).
- **Hooks** — `OnAppointmentCreated/Updated/Deleted` push directly to
  the configured `cfg.CalendarPath` on a 30s-timeout background goroutine
  so user requests don't block (`caldav_service.go:510–558`).
- **Approval flow (t-138)** — project-attached appointments may be
  `approval_status = 'pending'`. CalDAV push already runs after approval
  in `AppointmentService.Update` paths; `ApplyRemoteUpdate` from a remote
  edit currently bypasses the approval gate. That's a pre-existing hole
  flagged here only because multi-calendar makes "which calendar's edit
  wins" more visible — fix belongs in t-138 follow-ups, not in this
  design.
- **CalDAV verbs supported** — PUT / DELETE / GET / PROPFIND (depth 0
  and 1). No MKCALENDAR, no REPORT, no calendar-multiget. Tested
  against Nextcloud, Radicale, Baikal, mailcow SOGo per
  `caldav_client.go:22–24`.

**What is _not_ baked in and is therefore free to extend:**

- The 60s ticker is per-*user*, not per-*calendar*. Adding bindings does
  not multiply tickers.
- `cfg.CalendarPath` is referenced in exactly two places (`pushAll`,
  `pullAll`) plus the three hooks. Replacing it with a binding loop is
  a contained edit.
- Credentials are server-scoped, not calendar-scoped — every binding
  for the same user shares the existing decrypted credential, so the
  encryption layer (`caldav_crypto.go`) is untouched.

---

## §2 — Per-provider calendar-count limits (verified 2026-05-19)

Real numbers, from current docs, so the design knows its envelope.

| Provider | Per-account / per-user limit | Source |
|---|---|---|
| **iCloud** | **100** calendars + reminder-lists combined | [Apple Support 103188](https://support.apple.com/en-us/103188) |
| **Google Calendar** | **~100 owned** (soft recommendation, post-Nov-2025 ownership model) | [Workspace Updates 2026-01](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html), [usecarly.com summary](https://www.usecarly.com/blog/how-many-calendars-google-account/) |
| **Fastmail** | **No documented cap on calendars.** 100 000 events/user. | [Fastmail account-limits page](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) |
| **Nextcloud** | **30 per user** default; admin-configurable, `-1` = unlimited. Rate limit: 10 calendar-creations/hour. | [Nextcloud admin manual — Calendar](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) |
| **Radicale / Baikal / mailcow SOGo** | No published per-account cap (file-system / DB bound). | server defaults |

**Implications for the design:**

- "One calendar per project" is comfortably within all providers'
  envelopes for typical HLC caseloads. A senior PA who tracks 40
  litigations would land 40+ calendars, still inside iCloud's 100 and
  Nextcloud's default 30 (would need an admin bump on Nextcloud — flag
  in onboarding).
- "One calendar per case" can blow past Nextcloud's default 30 fast and
  is a real risk on iCloud at the 60+ mark when combined with the
  user's existing personal calendars + reminder lists. We should
  **soft-cap** scope choices at the UI layer (warn at 20 bindings, hard
  block at 80) rather than discover the limit by 5xx on PUT.
- Google Calendar's CalDAV endpoint does **not** support `MKCALENDAR`
  reliably — calendars must be pre-created in the Google UI. iCloud,
  Fastmail, Nextcloud, Radicale, Baikal, SOGo all accept `MKCALENDAR`.
  So the "auto-create a calendar per project" affordance is provider-
  dependent and must degrade gracefully ("we couldn't create it for
  you — please make `Project X` in your calendar app and paste its
  URL").

---

## §3 — Proposed data model

Three schema changes, no destructive migrations. The scalar
`appointments.caldav_uid` / `caldav_etag` columns survive as a
denormalised "default-binding" pointer through Slice 1 and 2; Slice 4
drops them after telemetry confirms no path still reads them.

### §3.1 New table: `paliad.user_calendar_bindings`

```sql
CREATE TABLE paliad.user_calendar_bindings (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
  calendar_path   text NOT NULL,                  -- absolute URL or path under user_caldav_config.url
  display_name    text NOT NULL DEFAULT '',       -- the label discovered via PROPFIND <displayname/>; what we show in the UI

  scope_kind      text NOT NULL,                  -- 'all_visible' | 'personal_only' | 'project' | 'client' | 'litigation' | 'patent' | 'case'
  scope_id        uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,  -- NULL for 'all_visible' / 'personal_only'
  include_personal boolean NOT NULL DEFAULT false, -- only meaningful when scope_kind <> 'all_visible'/'personal_only'

  enabled         boolean NOT NULL DEFAULT true,
  last_sync_at    timestamptz,
  last_sync_error text,

  created_at      timestamptz NOT NULL DEFAULT now(),
  updated_at      timestamptz NOT NULL DEFAULT now(),

  UNIQUE (user_id, calendar_path),                                -- can't bind one calendar twice for the same user
  UNIQUE (user_id, scope_kind, scope_id),                         -- one binding per scope per user — but a project can also be covered by 'all_visible'
  CHECK ((scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
      OR (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL))
);
CREATE INDEX user_calendar_bindings_user_idx ON paliad.user_calendar_bindings(user_id) WHERE enabled;
-- RLS: row visible/writable only when auth.uid() = user_id (mirrors user_caldav_config).
```

**Why per-scope unique but not per-appointment unique:** an Appointment in
project P is allowed to land in both the user's `all_visible` calendar
AND their `project=P` calendar — that's the explicit "master + per-project"
hybrid m asked about. What we forbid is two different `project=P` bindings
for the same user, which would have no useful semantics.

**`scope_kind = 'personal_only'`** is a separate scope from `'all_visible'`
because the existing pushAll already covers both personal and visible-project
appointments; users may want a "personal only" calendar that does *not*
get the noisy team events. Without this, every binding either includes
personal events or doesn't, and there's no way to say "the master
calendar = everything except personal".

### §3.2 New table: `paliad.appointment_caldav_targets`

```sql
CREATE TABLE paliad.appointment_caldav_targets (
  appointment_id uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
  binding_id     uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,
  caldav_uid     text NOT NULL,        -- still 'paliad-appointment-<uuid>@paliad.de' — same for all bindings of one appointment
  caldav_etag    text NOT NULL,
  last_pushed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (appointment_id, binding_id)
);
CREATE INDEX appointment_caldav_targets_binding_idx ON paliad.appointment_caldav_targets(binding_id);
-- RLS: visible/writable when the underlying binding's user_id = auth.uid().
```

**UID stays per-appointment, not per-binding.** That keeps the iCal UID
canonical (still `paliad-appointment-<uuid>@paliad.de`), so when a user
removes a binding and re-adds it later, the same UID rebinds without
spurious duplicates. The `.ics` filename in the calendar — `<uid>.ics`
— is also identical across bindings, which means the same UUID
shows up in different calendars on the same server but never collides
because they're under different `calendar_path` collections.

### §3.3 Row examples for the four common organisations

| Organisation | Rows in `user_calendar_bindings` |
|---|---|
| **A — one cal, everything** | 1 row: `scope_kind='all_visible'`, `calendar_path='/cal/work'` |
| **B — one cal per project** | N rows, all `scope_kind='project'`, distinct `(scope_id, calendar_path)` |
| **C — master + per-project hybrid** | 1 row `scope_kind='all_visible'` + N rows `scope_kind='project'`. Each project event appears in both. |
| **D — personal split from work** | 1 row `scope_kind='personal_only'` → `/cal/personal` + 1 row `scope_kind='all_visible'` (which will include the same personal events, so the user will more commonly pair `personal_only` with a `scope_kind='client'` per-client work view instead). |

### §3.4 What stays unchanged

- `paliad.user_caldav_config` — still holds the server URL, username,
  encrypted password, and a per-user `enabled` flag. The existing
  `calendar_path` column becomes a hint for the **default binding** we
  auto-create on migration and is no longer read by sync logic after
  Slice 1 ships. We keep it nullable-on-read for forwards-compat then
  drop in Slice 4.
- `paliad.caldav_sync_log` — still per-user; sync entries gain a
  `binding_id` column (nullable for legacy rows) so the UI can show
  per-calendar last-sync state.
- iCal serialisation (`caldav_ical.go`) — unchanged. Same VEVENT
  formatter feeds every binding.
- AES-GCM credential encryption (`caldav_crypto.go`) — unchanged.

---

## §4 — Sync engine implications

The shape of the per-user goroutine stays. The body of `syncOnce`
moves from "push to one path / pull from one path" to "for each
enabled binding, push the scope-filtered slice / pull from that path".

### §4.1 Push fan-out

```go
// pseudocode for the new pushAll body
bindings := s.bindings.ListEnabled(ctx, userID)              // 1..N rows
for _, b := range bindings {
    appts := s.appointments.ForBinding(ctx, userID, b)       // scope-filtered
    for _, a := range appts {
        body := formatAppointment(&a)
        etag, err := cli.PutEvent(ctx, b.CalendarPath, terminUID(a.ID), body)
        if err != nil { continue }                            // best-effort, per-binding error
        s.targets.Upsert(ctx, a.ID, b.ID, terminUID(a.ID), etag)
    }
    // Remove events from this calendar that no longer belong to the scope.
    for _, stale := range s.targets.DanglingForBinding(ctx, b.ID, currentIDs(appts)) {
        cli.DeleteEvent(ctx, b.CalendarPath, stale.CalDAVUID)
        s.targets.Delete(ctx, stale.AppointmentID, b.ID)
    }
}
```

`ForBinding(userID, b)` is the scope filter:

- `all_visible` → existing `AllForUser(userID)`
- `personal_only` → appointments with `project_id IS NULL AND created_by = userID`
- `project` → appointments where `project_id = scope_id` AND visible to user
- `client` / `litigation` / `patent` / `case` → appointments where the
  ancestor at the relevant hierarchy level = `scope_id` AND visible to user
- when `include_personal = true`, union with personal events on top of the above (only for non-`all_visible`/`personal_only` scopes)

This reuses the existing `can_see_project()` predicate (per project
CLAUDE.md, team-based RLS), so visibility shrinkage on a project unshare
falls out naturally: next push sees the appointment is no longer in
`ForBinding(...)`, sees a dangling target row, issues `DeleteEvent`.

### §4.2 Pull reconciliation

Each binding has its own pull pass against `b.CalendarPath`. The
matching key is still `caldav_uid` — same UID across all bindings, so
`appointments.FindByCalDAVUID(uid)` resolves the local row. The
**ETag check is per-target row** now, not per-appointment: a remote
edit in calendar X bumps the etag in `appointment_caldav_targets` for
binding X only. The local Appointment is updated once (last-write-wins
on Appointment.updated_at), the next push tick re-syncs the other
bindings with the new payload (they see their stored etag is older
than the appointment's `updated_at` and re-PUT).

**One subtle change:** the foreign-UID skip (`extractAppointmentID == ""`)
still applies per-binding pull. That preserves the v1 "Paliad owns its
UIDs" property — multi-calendar does not open the door to importing
events the user creates in their calendar app. (If/when that becomes
in-scope, it's a separate t-paliad-* design.)

### §4.3 Hooks (instant push)

`OnAppointmentCreated/Updated/Deleted` fan out across all the user's
enabled bindings that match the appointment's scope. Same 30s-timeout
background goroutine. The user-facing request still returns
immediately; the failure mode is identical (best-effort per binding,
logged on `slog.Warn`).

### §4.4 Bandwidth & rate limits

- Per user per tick: **N bindings × 1 PROPFIND + per-event GETs**.
  The pull GET is the dominant cost; a 50-binding user with 20 events
  per calendar is ~1 000 GETs/min, which is fine over HTTP/1.1 to a
  decent CalDAV server but **does** put us inside iCloud's
  ~throttle-friendly band and risks Google's quota model.
- Mitigation: switch pull to **`REPORT` `calendar-multiget`** so each
  binding's events come back in one round-trip. That's a single
  iteration on `caldav_client.go` (the same multistatus parser
  already handles the body) and pays for itself the moment a user
  has >10 events per binding. We deliberately deferred this in
  Phase F (one calendar, low volume) — multi-calendar makes it
  table-stakes. Plan to land it in **Slice 2** alongside the picker.
- Rate limiting on the Paliad side: keep the 60s ticker, but stagger
  per-binding pulls so we never fire N concurrent PROPFINDs against
  the same provider. Sequential per binding is fine; we already do
  this implicitly with the per-user goroutine.

### §4.5 Server-side cleanup on binding delete

User deletes a binding → service:

1. Lists every (appointment, binding) target row for that binding.
2. Issues `DELETE` per `.ics` on the remote calendar (best effort).
3. Deletes the target rows.
4. Deletes the binding row (or relies on `ON DELETE CASCADE` from
   target FK — cleaner to delete remotely first, then drop the row,
   so a half-failed cleanup leaves rows we can retry on next tick).

A "leave events behind in the external calendar" toggle is a real
ask (users sometimes archive bindings without wanting their calendar
app to suddenly empty). Plumb it as `binding.cleanup_on_delete bool`
in Slice 2 if there's demand; default `true` (delete).

---

## §5 — Bidirectional vs one-way

**Recommendation: stay bidirectional, identical to today's semantics,
per-binding.** Reasons:

1. **m's stated workflow expects round-trip.** Drag a deadline in
   Outlook → Paliad sees the new date → approval flow triggers
   (t-138). One-way push breaks that. Multi-calendar doesn't change
   this expectation; if anything, it strengthens it (the user picked
   the project-cal binding *because* they intend to edit there).
2. **The conflict model is already in place.** Last-write-wins on
   ETag, foreign-UID skip, `LogConflict` audit append. Multi-calendar
   adds one new question: "if the user edits the same event in two
   different bindings between ticks, which wins?" Answer: the one
   that lands first in our pull pass. Bindings are iterated in
   `created_at` order so the behaviour is deterministic, and the
   second edit gets overwritten on the next tick when we re-push the
   resolved appointment to it. Acceptable trade-off; would only show
   up if a user actually edits the same event in two of their own
   calendars within 60s, which is vanishingly rare.
3. **Approval-flow integration is unchanged.** Pending-approval
   events have the `[PENDING APPROVAL]` marker baked into the iCal
   summary by `caldav_ical.go:76+`. That marker survives multi-binding
   fan-out untouched; an external edit on a pending event still has
   the pre-existing bypass-the-gate hole (flagged §1, not in scope).

**Tee-up for m's call:** if multi-calendar is the wrong moment to
keep bidirectional (e.g. because per-project calendars are about
**read-only visibility for partners**, not editing), we'd add a
`binding.read_only bool` column and skip the pull pass for that
binding. Cheap to add now or later. **I recommend defaulting
`read_only = false` (bidirectional like today) and only making it
optional if m's first session with the UI surfaces the need.**

---

## §6 — User-facing config model

Surface on `/einstellungen/caldav` (already exists for Phase F creds).
Two sections, in this order:

1. **Server** (existing) — URL, username, password, "test connection".
   Unchanged.
2. **Calendars** (new) — list of bindings as cards / rows. For each:
   `display_name`, `calendar_path`, `scope_kind` chip (master /
   personal / project / …), `enabled` toggle, last-sync status, action
   buttons "Edit scope" / "Remove".
3. **Add a calendar** — flow:
   - **a)** click "Add". Modal opens. We do a `PROPFIND
     <calendar-home-set>` against the user's server to discover their
     existing calendars; show as a picker. (RFC 6638 / 4791 calendar
     home set discovery — supported by iCloud, Fastmail, Nextcloud,
     Radicale, Baikal, SOGo. Google CalDAV does not expose this
     reliably; for Google users we degrade to a manual path entry box.)
   - **b)** user picks an existing calendar, or chooses "Create new
     calendar". Create-new attempts `MKCALENDAR` (works on iCloud,
     Fastmail, Nextcloud, Radicale, Baikal, SOGo; fails on Google →
     friendly error with copy-paste instruction).
   - **c)** user picks the **scope**: a radio between "Everything I can
     see", "Personal only", "One project", and (later) "One client /
     litigation / patent / case". Project picker uses the existing
     `/api/projects?…` autocomplete.
   - **d)** "Save" → POST `/api/caldav-bindings`. The next 60s tick
     starts pushing into the new calendar; the UI shows "Initial
     sync running…" with a live last-sync indicator (already polled
     by the existing `caldav-config` page).

4. **Quick-add affordances** (Slice 3 polish, not v1):
   - On a project's `/projects/<id>` page: "Open in calendar app" link
     if a binding already exists for that project, "Pin to a new
     calendar" if none does (deep-links to the Add-a-calendar modal
     pre-filled).
   - Bulk action "Create one calendar per active litigation" on
     `/einstellungen/caldav` (requires `MKCALENDAR` support; gated
     behind a server-capability probe at first PROPFIND).

5. **Soft limits in the UI:**
   - At **20 bindings**: yellow info banner "Most users keep ≤ 20
     calendars; review your list before adding more."
   - At **80 bindings**: red error, block adding new (we don't know
     the user's provider for sure; 80 is a safe ceiling for iCloud
     and Nextcloud-default).
   - Provider hint surfaced under the Server form: parsed from the
     URL host, with a "your provider's documented limit" line —
     pure courtesy, not enforced.

### §6.1 What the API contract looks like

| Verb + Path | Body / Returns | Notes |
|---|---|---|
| `GET /api/caldav-bindings` | array of binding rows + sync status | replaces having to interpret `user_caldav_config.calendar_path` |
| `POST /api/caldav-bindings` | `{calendar_path, display_name, scope_kind, scope_id?, include_personal?}` → created binding | triggers immediate sync goroutine wake-up |
| `PATCH /api/caldav-bindings/{id}` | partial; toggle `enabled` or change `scope_*` | re-runs `pushAll` for this binding |
| `DELETE /api/caldav-bindings/{id}` | — | deletes external events first, then row |
| `GET /api/caldav-discover` | array of `{href, displayname}` from server `<calendar-home-set>` | populates the picker; cached 5 min |
| `POST /api/caldav-mkcalendar` | `{display_name, color?}` → `{calendar_path}` | issues `MKCALENDAR`; returns 501 on Google |

`GET /api/caldav-config` still works (back-compat for the server-creds
section); its `calendar_path` field is documented as "deprecated, see
/api/caldav-bindings".

---

## §7 — Slice plan

Tracer-bullet slices so each is independently shippable, safe to
revert, and gives the user something they can see.

**Slice 1 — Schema + backfill (no UI change).**
- Migration: create `user_calendar_bindings`, `appointment_caldav_targets`.
- Backfill: for every existing `user_caldav_config` row, insert one
  `bindings` row `(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled)`.
  For every Appointment with non-null `caldav_uid`, insert one
  `appointment_caldav_targets` row pointing at the user's new default
  binding.
- Refactor `CalDAVService.syncOnce` / `pushAll` / `pullAll` to drive
  off bindings (loop of length 1 per existing user). Behaviour
  observably identical: same calendars, same events, same logs.
- `appointments.caldav_uid` / `caldav_etag` columns still exist and
  are written for compatibility (treat them as denormalised pointers
  to the default binding's target row). UI unchanged.
- **Exit criterion:** existing users see no change in their calendar;
  `caldav_sync_log.binding_id` is populated for all new rows; manually
  inserted second binding via SQL syncs correctly end-to-end on a
  staging account.

**Slice 2 — Binding-picker UI + multi-binding support.**
- `/api/caldav-bindings` CRUD + `/api/caldav-discover` (PROPFIND
  `calendar-home-set`) + `/api/caldav-mkcalendar`.
- New "Calendars" section on `/einstellungen/caldav` with the modal
  flow from §6.
- **Land `REPORT calendar-multiget` pull** alongside (per §4.4).
  Required, not optional, for the bandwidth profile multi-binding
  introduces.
- Scope kinds enabled in v1: `all_visible`, `personal_only`, `project`.
  Hierarchy scopes (`client`, `litigation`, `patent`, `case`) parked
  for Slice 3.
- **Exit criterion:** m can pin a second calendar via the UI on
  staging; events for project X appear only in the X-bound calendar
  if his master binding is disabled, and in both if it's enabled.

**Slice 3 — Hierarchy scopes + project-page quick-adds.**
- Enable `scope_kind ∈ {client, litigation, patent, case}` — pure
  filter-predicate change in `ForBinding(...)` using the existing
  project-tree walker.
- "Pin to a new calendar" button on `/projects/<id>` and on the
  /einstellungen page.
- Bulk "calendar-per-active-litigation" provisioner (with
  `MKCALENDAR` capability probe).
- **Exit criterion:** real HLC PA can set up "one cal per
  litigation" in <5 min on first try without inventor help.

**Slice 4 — Polish + cleanup.**
- Drop `appointments.caldav_uid` / `caldav_etag` after instrumentation
  shows zero readers outside `CalDAVService` (`grep` + a one-week
  query-log audit on the read replica).
- Soft-limit banners (20 / 80).
- `binding.read_only` and `binding.cleanup_on_delete` toggles if
  asked for by then.
- **Exit criterion:** schema is final; no legacy paths remain in
  `caldav_service.go`.

**(Out of scope across all four slices:** foreign-UID import, custom
event types per binding, per-binding colour mapping, MKCALENDAR for
Google. These are easy to add later if the data says so.)

---

## §8 — Open questions for m

1. **Bidirectional default for new bindings: yes/no?** I recommend
   **yes** (matches today's single-cal behaviour and the round-trip
   workflow expectation). A `read_only` per-binding flag is cheap to
   add later if a real use case shows up. Decide now → Slice 1; decide
   later → Slice 4.
2. **`personal_only` scope — keep or drop?** It's useful for users
   who want a "noisy team master + clean personal" split, but it's
   redundant for users who only use the master calendar. I'd keep
   it; trivial to remove if m disagrees.
3. **`MKCALENDAR` (auto-create calendar) — ship in Slice 2 or defer
   to Slice 3?** Shipping it in Slice 2 means we need the
   capability-probe + Google-degrade UX up-front. Deferring means
   Slice 2 users have to pre-create the calendar in their app and
   paste the URL — workable but clunky. Default plan: **Slice 2,
   with a clean Google-degrade message**.
4. **Soft cap numbers (20 / 80) — sensible?** Picked from §2
   provider limits + "most paliad users will pick 1–5". m may
   want different numbers — easy to tune.
5. **`/admin/caldav-bindings` view for support debugging?** Not in
   the slice plan; useful if a user calls confused about which
   calendar holds which event. Add if m wants it.
6. **Approval-flow + remote-edit gap (§1, the bypass) — fix scope?**
   Pre-existing in single-cal Phase F. Multi-cal makes it more
   visible. Should this be a follow-up under t-138, or folded into
   Slice 3? I'd file as a separate task.

---

## §9 — Why this is the right shape

- **Single CalDAV server per user, N bindings.** Matches every real
  provider's auth model (one auth blob covers all the user's
  calendars) and keeps `caldav_crypto.go` and `user_caldav_config`
  untouched.
- **Binding scope is a row, not a static config.** Users compose
  the organisation they want without us guessing; defaults (one
  master binding on migration) preserve current behaviour.
- **UID stays per-appointment.** Means an event re-binding (move
  from project-cal to master-cal) is just shuffling target rows,
  not minting new UIDs. Re-importing into the same calendar later
  rebinds cleanly.
- **Sync engine shape is unchanged.** Same per-user goroutine, same
  60s tick, same hooks. The blast radius of multi-binding is one
  inner loop, gated behind a feature that backfills to a no-op for
  every existing user.
- **Slices give m a vertical demo at each step.** Slice 1 is
  invisible-but-shippable; Slice 2 is the first user-facing change
  ("you can pin a second calendar"); Slice 3 is "now organise by
  project tree"; Slice 4 is cleanup.
- **No new external dependencies.** Same hand-rolled CalDAV client.
  Adds one new verb (`MKCALENDAR`) and one new report
  (`calendar-multiget`) — both small, both already half-tested
  against `caldav_client.go`'s patterns.

---

## §10 — Sources

- [Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps](https://support.apple.com/en-us/103188) — iCloud 100 combined calendars + reminder lists.
- [Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026](https://workspaceupdates.googleblog.com/2026/01/automatic-addition-owned-secondary-calendars.html) — Google ~100 owned recommendation.
- [Fastmail — Account limits](https://www.fastmail.help/hc/en-us/articles/1500000277382-Account-limits) — 100k events/user, no documented calendar count cap.
- [Nextcloud admin manual — Calendar / CalDAV](https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html) — default 30, configurable, 10/hr rate limit.
- Live verification against `internal/services/caldav_*.go` and `paliad.user_caldav_config` / `paliad.appointments` schema on the youpc Supabase instance.

---

## Addendum — m's decisions (2026-05-19)

Walked through §8.1–§8.6 with m via AskUserQuestion. Decisions are
locked in for the coder shift; revisit only on Slice-3 feedback.

| Q | Decision | Implication for the slice plan |
|---|---|---|
| **§8.1 — Bidirectional default** | **Yes — bidirectional by default** | No `read_only` flag in Slice 1–3. Multi-cal inherits Phase F's last-write-wins / foreign-UID-skip semantics unchanged. Per-binding `read_only` only added later if a real use case shows up. |
| **§8.2 — `personal_only` scope** | **Keep — first-class scope** | Ships in Slice 2 as one of the picker's radio options (`Everything I can see` / `Personal only` / `One project`). One enum value, one `ForBinding()` branch. |
| **§8.3 — MKCALENDAR timing** | **Slice 2 with Google-degrade UX** | Slice 2 includes `POST /api/caldav-mkcalendar` + capability probe. Google users get a friendly "create the calendar in your Google UI, paste the URL" fallback. iCloud / Fastmail / Nextcloud / Radicale / Baikal / SOGo get one-click "Create new calendar". |
| **§8.4 — Soft caps** | **No caps in v1, add later if data warrants** | Drop the 20-warn / 80-block UI guards from §6. Instrument `count(*)` on `user_calendar_bindings` per user as a Slice 2 telemetry add. Revisit if/when real distributions land. |
| **§8.5 — `/admin/caldav-bindings` view** | **Don't ship in v1** | Stays out of the slice plan. Support debugging goes via Supabase SQL until a real ticket lands. Frees Slice 4 polish for the legacy-column drop only. |
| **§8.6 — Approval-flow remote-edit gap** | **Separate task under t-138** | Out of scope for all four multi-cal slices. File the gap as a new `t-paliad-*` follow-up under t-138 so multi-cal stays clean and reverter-friendly. Pre-existing hole, surfaced not fixed. |

### Net effect on §7 slice plan

- **Slice 1** unchanged — schema + backfill, behaviour-equivalent.
- **Slice 2** = picker UI + `REPORT calendar-multiget` + **MKCALENDAR
  with capability probe + Google-degrade message** + binding-count
  telemetry. No `read_only` flag, no soft caps, no admin view.
  Scopes enabled: `all_visible`, `personal_only`, `project`.
- **Slice 3** = hierarchy scopes (`client` / `litigation` / `patent` / `case`)
  + per-project quick-adds. **No** approval-gap fix folded in.
- **Slice 4** = drop legacy `appointments.caldav_uid` / `caldav_etag`.
  Soft-cap banners only if Slice 2 telemetry says we need them.

### Net effect on §3 schema

No change. `user_calendar_bindings` still ships with the full
`scope_kind` enum (including `personal_only`). `appointment_caldav_targets`
unchanged. No `read_only` column in v1.

### Follow-ups to file as separate tasks

1. **`t-paliad-*` (under t-138):** approval-flow + CalDAV remote-edit
   gap. `ApplyRemoteUpdate` bypasses the approval gate when an external
   client edits a pending-approval event. Pre-existing in single-cal
   Phase F. Owner: t-138 maintainer.
2. **(maybe) `t-paliad-*`:** soft-cap UI if Slice 2 telemetry shows
   any user near the iCloud-100 / Nextcloud-30 envelope. Not pre-filed
   — only opens if data warrants.
