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.
32 KiB
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_configis 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 scalarcalendar_pathis the only handle on which external calendar receives events. Per directinformation_schemaquery. - Appointment binding —
paliad.appointmentscarries scalarcaldav_uid textandcaldav_etag text(nullable). Set once after a successful PUT viaAppointmentService.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).AllForUserreturns every personal-or-visible-project appointment for the user; today they all funnel into the singlecalendar_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/Deletedpush directly to the configuredcfg.CalendarPathon 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 inAppointmentService.Updatepaths;ApplyRemoteUpdatefrom 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.CalendarPathis 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
MKCALENDARreliably — calendars must be pre-created in the Google UI. iCloud, Fastmail, Nextcloud, Radicale, Baikal, SOGo all acceptMKCALENDAR. So the "auto-create a calendar per project" affordance is provider- dependent and must degrade gracefully ("we couldn't create it for you — please makeProject Xin 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-userenabledflag. The existingcalendar_pathcolumn 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 abinding_idcolumn (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→ existingAllForUser(userID)personal_only→ appointments withproject_id IS NULL AND created_by = userIDproject→ appointments whereproject_id = scope_idAND visible to userclient/litigation/patent/case→ appointments where the ancestor at the relevant hierarchy level =scope_idAND visible to user- when
include_personal = true, union with personal events on top of the above (only for non-all_visible/personal_onlyscopes)
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
REPORTcalendar-multigetso each binding's events come back in one round-trip. That's a single iteration oncaldav_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:
- Lists every (appointment, binding) target row for that binding.
- Issues
DELETEper.icson the remote calendar (best effort). - Deletes the target rows.
- Deletes the binding row (or relies on
ON DELETE CASCADEfrom 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:
- 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).
- The conflict model is already in place. Last-write-wins on
ETag, foreign-UID skip,
LogConflictaudit 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 increated_atorder 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. - Approval-flow integration is unchanged. Pending-approval
events have the
[PENDING APPROVAL]marker baked into the iCal summary bycaldav_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:
-
Server (existing) — URL, username, password, "test connection". Unchanged.
-
Calendars (new) — list of bindings as cards / rows. For each:
display_name,calendar_path,scope_kindchip (master / personal / project / …),enabledtoggle, last-sync status, action buttons "Edit scope" / "Remove". -
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 existingcaldav-configpage).
- a) click "Add". Modal opens. We do a
-
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(requiresMKCALENDARsupport; gated behind a server-capability probe at first PROPFIND).
- On a project's
-
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_configrow, insert onebindingsrow(user_id, calendar_path, display_name='', scope_kind='all_visible', enabled). For every Appointment with non-nullcaldav_uid, insert oneappointment_caldav_targetsrow pointing at the user's new default binding. - Refactor
CalDAVService.syncOnce/pushAll/pullAllto drive off bindings (loop of length 1 per existing user). Behaviour observably identical: same calendars, same events, same logs. appointments.caldav_uid/caldav_etagcolumns 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_idis 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-bindingsCRUD +/api/caldav-discover(PROPFINDcalendar-home-set) +/api/caldav-mkcalendar.- New "Calendars" section on
/einstellungen/caldavwith the modal flow from §6. - Land
REPORT calendar-multigetpull 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 inForBinding(...)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
MKCALENDARcapability 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_etagafter instrumentation shows zero readers outsideCalDAVService(grep+ a one-week query-log audit on the read replica). - Soft-limit banners (20 / 80).
binding.read_onlyandbinding.cleanup_on_deletetoggles 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
- Bidirectional default for new bindings: yes/no? I recommend
yes (matches today's single-cal behaviour and the round-trip
workflow expectation). A
read_onlyper-binding flag is cheap to add later if a real use case shows up. Decide now → Slice 1; decide later → Slice 4. personal_onlyscope — 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.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.- 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.
/admin/caldav-bindingsview 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.- 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.goanduser_caldav_configuntouched. - 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 againstcaldav_client.go's patterns.
§10 — Sources
- Apple Support — Limits for iCloud Contacts, Calendars, Reminders, Bookmarks, and Maps — iCloud 100 combined calendars + reminder lists.
- Google Workspace Updates — Automatic addition of owned secondary calendars, Jan 2026 — Google ~100 owned recommendation.
- Fastmail — Account limits — 100k events/user, no documented calendar count cap.
- Nextcloud admin manual — Calendar / CalDAV — default 30, configurable, 10/hr rate limit.
- Live verification against
internal/services/caldav_*.goandpaliad.user_caldav_config/paliad.appointmentsschema 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. Noread_onlyflag, 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
t-paliad-*(under t-138): approval-flow + CalDAV remote-edit gap.ApplyRemoteUpdatebypasses the approval gate when an external client edits a pending-approval event. Pre-existing in single-cal Phase F. Owner: t-138 maintainer.- (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.