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

67 lines
3.7 KiB
Markdown

# Roadmap
Milestones from the implementation plan. See SPECS.md for the architecture and the
sync semantics these items implement.
## M0 — skeleton (done)
- [x] Initialise jj colocated with git; `.gitignore` written before the first build
- [x] `devbox.json` pinning Rust 1.97.1, pimsync 0.5.11, jujutsu 0.44.0, radicale 3.7.8
- [x] Configuration model with referential validation, reporting every problem in one pass
- [x] `calcalist doctor` — pimsync presence and version series, state directory, configuration
- [x] Define the full CLI surface; unimplemented commands exit 2 rather than pretend
- [x] SPECS.md: Rust naming conventions, `devbox run check` gate, recorded sync semantics
## M1 — bidirectional sync
Core modules:
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
resolved target endpoint id **and** backend type
- [x] `vdir.rs` — read and write vdir directories
- [x] `ical.rs` — surgical line-level `.ics` editing (UID rewrite, property injection),
respecting RFC 5545 folding; no parse-and-reserialize
- [x] `provenance.rs` — deterministic `blake3(aggregate_id, source_id, source_uid)` UIDs
- [x] `mirror.rs` — the to-aggregate and to-source transforms (added; not in the
original plan, which folded these into `reconcile`)
- [x] `reconcile.rs` — the aggregation engine; pure, no I/O
- [x] `sync.rs` — one cycle over the local vdirs, applying what `reconcile` decides
- [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
drive one-shot `pimsync sync` bracketing the reconcile step
- [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's
parser does not always match its documentation
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
- [ ] `google/api.rs`, `google/convert.rs`
- [x] 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.
- [x] `sync` refuses to run on aggregate target drift, before reconciliation
- [x] `aggregate retarget` — flush unrouted creations against the old target, then
re-materialise; keep old orphans by default
- [x] Mass-deletion guard (`max_delete_fraction`), overridable with `--force`, with an
absolute floor so deleting a couple of events is never refused
- [x] Echo suppression: derived UIDs are never re-ingested as source events
Tests:
- [x] `reconcile` table-driven cases: create/update/delete each direction, both-sides-changed,
routing, echo suppression, mass-delete abort
- [x] `ical` round-trip fixtures: recurring with overrides, all-day, TZID, unknown `X-` props
- [ ] Integration against Radicale plus a `file://` WebCal fixture; assert idempotence
- [x] 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
- [x] 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`