CalCalist/TODO.md
randogoth dd984e150a Add aggregate retarget
Moving an aggregate to a different endpoint is the sharpest failure mode in the
design, so it is a command rather than something a config edit triggers: sync
already refuses on target drift and points here.

The move settles against the old target first. An event a user created in the
aggregate and that has not yet reached a sink exists only there, and would be
lost the moment we stop looking at that calendar. Only then is every mapped event
rebuilt on the new target — a rewrite rather than a copy, since the rendered
content differs between backends that can and cannot suppress scheduling.

Orphans on the old target are kept unless --purge-old, and a purge is bounded by
the derivation, so events the user keeps in that calendar themselves survive.

Also fixed prepare_vdirs, which created a directory per endpoint. That told
pimsync a local collection existed before its remote counterpart had been seen,
so it tried to create the counterpart: unsupported for a read-only feed, and it
would have invented calendars on a CalDAV server. Only the vdir root is created
now; collections are pimsync's to make from what it discovers.

One test premise was wrong rather than the code: a foreign event in an aggregate
that has a sink is a user-created event and gets adopted into that sink, so it
never reaches a purge as an outsider. Both behaviours are now covered.

Verified against the live Posteo calendar after the change: still a clean no-op.

106 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 13:08:41 +03:00

3.7 KiB

Roadmap

Milestones from the implementation plan. See SPECS.md for the architecture and the sync semantics these items implement.

M0 — skeleton (done)

  • Initialise jj colocated with git; .gitignore written before the first build
  • devbox.json pinning Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0, radicale 3.7.8
  • Configuration model with referential validation, reporting every problem in one pass
  • calcalist doctor — pimsync presence and version series, state directory, configuration
  • Define the full CLI surface; unimplemented commands exit 2 rather than pretend
  • SPECS.md: Rust naming conventions, devbox run check gate, recorded sync semantics

M1 — bidirectional sync

Core modules:

  • state.rs — JSON sidecar, atomic temp + fsync + rename; records each aggregate's resolved target endpoint id and backend type
  • vdir.rs — read and write vdir directories
  • ical.rs — surgical line-level .ics editing (UID rewrite, property injection), respecting RFC 5545 folding; no parse-and-reserialize
  • provenance.rs — deterministic blake3(aggregate_id, source_id, source_uid) UIDs
  • mirror.rs — the to-aggregate and to-source transforms (added; not in the original plan, which folded these into reconcile)
  • reconcile.rs — the aggregation engine; pure, no I/O
  • sync.rs — one cycle over the local vdirs, applying what reconcile decides
  • pimsync.rs — generate pimsync.conf (with on_empty skip and on_delete skip), drive one-shot pimsync sync bracketing the reconcile step
  • doctor asks pimsync check to validate the generated config, since pimsync's parser does not always match its documentation
  • google/auth.rs — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
  • google/api.rs, google/convert.rs
  • Reintroduce SchedulingSuppression in config.rs (removed in M0 as dead code)

Safety-critical behaviour:

  • events.import gate — do this first. Import an attendee-bearing event whose guests are on a mail sink we control and confirm no mail is emitted; repeat for update and delete under sendUpdates=none. The Google attendee path depends on it. Fallback if it fails: the same demotion transform used for CalDAV.
  • sync refuses to run on aggregate target drift, before reconciliation
  • aggregate retarget — flush unrouted creations against the old target, then re-materialise; keep old orphans by default
  • Mass-deletion guard (max_delete_fraction), overridable with --force, with an absolute floor so deleting a couple of events is never refused
  • Echo suppression: derived UIDs are never re-ingested as source events

Tests:

  • reconcile table-driven cases: create/update/delete each direction, both-sides-changed, routing, echo suppression, mass-delete abort
  • ical round-trip fixtures: recurring with overrides, all-day, TZID, unknown X- props
  • Integration against Radicale plus a file:// WebCal fixture; assert idempotence
  • Safety (unit level): no live ATTENDEE/ORGANIZER on a CalDAV-targeted mirror, VALARM intact, PARTSTAT: DECLINED maps to TRANSP: TRANSPARENT, bulk deletion aborts
  • Safety (integration): the same against a real Radicale instance with an SMTP sink, proving no mail is emitted
  • Retarget: drift makes sync exit non-zero having written nothing and losing no source event; purge is bounded by the derivation; an unrouted creation reaches a sink first

M2 — interface and packaging

  • axum configuration UI, bound to 127.0.0.1
  • OAuth loopback redirect handler
  • systemd user units: calcalist.service (oneshot) and calcalist.timer