CalCalist/TODO.md

65 lines
3.5 KiB
Markdown
Raw Normal View History

# 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:
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
resolved target endpoint id **and** backend type
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [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
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [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
- [ ] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
drive one-shot `pimsync sync`
- [ ] `google/auth.rs`, `google/api.rs`, `google/convert.rs`
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [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.
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [x] `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
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [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:
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [x] `reconcile` table-driven cases: create/update/delete each direction, both-sides-changed,
routing, echo suppression, mass-delete abort
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [x] `ical` round-trip fixtures: recurring with overrides, all-day, TZID, unknown `X-` props
- [ ] Integration against Radicale plus a `file://` WebCal fixture; assert idempotence
Add the aggregation engine and a local sync cycle The core of M1: everything needed to reconcile source calendars against an aggregate, short of getting events in and out over the network. - ical: surgical line-level editing. Each logical line keeps a byte range into the original, so untouched lines are emitted verbatim and only edited ones are rebuilt. Parsing and re-serialising would drop every property we do not model. - ical: content hashing excludes DTSTAMP and LAST-MODIFIED. Servers rewrite them on every store, so hashing them would report a change on every cycle forever. - provenance: aggregate UIDs derived as blake3(aggregate, source, source_uid), length-prefixed so field boundaries cannot collide. Deriving rather than recording makes the state file a cache, and makes our own mirrors recognisable, which is what stops writes echoing back around. - mirror: the transforms. An aggregate copy must be scheduling inert, so writing it never mails invitations for a meeting already invited from its source. Google can suppress notification and keeps real attendees; CalDAV cannot, so the guest list is demoted to inert data and a declined meeting is marked TRANSP:TRANSPARENT. Writing an edit back uses the source as donor for what the demotion removed, so editing a time cannot silently drop the guests. - reconcile: pure decision engine. Only the source changed updates the mirror, only the aggregate changed writes back, both changed keeps the source and logs a conflict. - Mass-deletion guard takes an absolute floor as well as a fraction: a share alone is meaningless at small counts, where deleting the only event is 100%. - sync refuses to run when an aggregate's configured target differs from the recorded one, before reconciling. Otherwise the new empty target would read as an aggregate whose every event was deleted, and delete propagation would then remove them from every source. Found by end-to-end testing: writing an item already present under a different filename created a duplicate rather than replacing it, because filenames are derived from the UID while pimsync picks its own. Writes now carry the path they supersede. Covered by a regression test. 79 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 09:55:06 +03:00
- [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
- [ ] Retarget: drift makes `sync` exit non-zero having written nothing and losing no source
event (verified by hand end to end; still needs an automated test)
## 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`