Files
paliad/docs/design-caldav-multi-calendar-2026-05-19.md
mAi 8e0e4c9dcc docs(caldav): fold m's decisions on the 6 open Qs into the design (t-paliad-212)
Addendum after §10 captures m's picks (2026-05-19, via AskUserQuestion):
§8.1 bidirectional default: YES; §8.2 personal_only: KEEP first-class;
§8.3 MKCALENDAR: Slice 2 with Google-degrade; §8.4 soft caps: NONE in
v1 (add later if telemetry warrants); §8.5 admin view: don't ship;
§8.6 approval-flow remote-edit gap: separate task under t-138.

Net effect: drops the 20-warn/80-block UI guards from §6 and the
`read_only` flag from §3; Slice 2 gains MKCALENDAR + binding-count
telemetry; §8.6 fix filed separately so multi-cal slices stay clean.
2026-05-19 10:43:20 +02:00

32 KiB
Raw Blame History

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.

  • Schemapaliad.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 bindingpaliad.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 engineinternal/services/caldav_service.go:298502. One goroutine per enabled user, 60s ticker, runSyncOncesyncOncepushAll (AppointmentService.AllForUser × cli.PutEvent) + pullAll (cli.PropfindCalendarcli.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 conventionpaliad-appointment-<uuid>@paliad.de (caldav_ical.go:3134). Foreign UIDs are intentionally skipped on pull (caldav_service.go:436442).
  • HooksOnAppointmentCreated/Updated/Deleted push directly to the configured cfg.CalendarPath on a 30s-timeout background goroutine so user requests don't block (caldav_service.go:510558).
  • 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:2224.

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
Google Calendar ~100 owned (soft recommendation, post-Nov-2025 ownership model) Workspace Updates 2026-01, usecarly.com summary
Fastmail No documented cap on calendars. 100 000 events/user. Fastmail account-limits page
Nextcloud 30 per user default; admin-configurable, -1 = unlimited. Rate limit: 10 calendar-creations/hour. Nextcloud admin manual — Calendar
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

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

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

// 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 15". 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


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 13. 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.