CalCalist/TODO.md
randogoth ef6482ed62 Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its
audience would be people who find TOML hard, but with bring-your-own OAuth
client settled, every user must first create a Google Cloud project, configure
a consent screen and put a secret in a keyring — a far higher bar than editing
thirty lines of config. Anyone who clears it can edit the file; anyone who
cannot never reaches the file. Against that stood three dependencies, five
modules, an auth.rs refactor and a security surface guarding something that
reads the config and touches the keyring. `doctor` and `status` had already
absorbed most of what it was for. The reasoning is recorded in TODO.md and
SPECS.md rather than left as an apparent oversight.

The run lock is not a UI feature and closes a gap that already existed: nothing
stopped a timer firing into a hand-run cycle, and two cycles interleaving
writes over the same vdirs is what the design otherwise avoids. flock is used
rather than a pid file because the kernel releases it however the process ends,
so a crash cannot leave a lock to clear by hand — which also means a lock we
failed to take is held by a live process, so the pid in it is worth reporting.

The one idea worth keeping from the UI design was collection discovery, which
needed no web layer. `calcalist discover` prints a ready-to-paste endpoint
block per calendar a server offers, removing the most error-prone field in the
config. pimsync's discovery output is undocumented, so the format was
established against a real server first. Two things it teaches: everything
arrives on stdout including failures, and a pair has two storages, so pimsync
reports the scratch vdir's contents too — parsing anchors on the heading naming
the server, or a probe directory's leftovers would be offered as the user's
calendars.

Verified against Posteo as well as Radicale: all four calendars found, the
first matching the URL already configured.

Also fixes a real defect in the test harness rather than its symptom. Ports were
chosen by binding one and letting go, so two tests could pick the same number —
and the loser's readiness check then succeeded against the winner's server,
silently sharing it. Startup now confirms the child we spawned is the one alive,
retries on another port if not, and waits for a real HTTP response rather than
an open socket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:23:49 +03:00

175 lines
10 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
Complete. Every item from the plan is built, tested and verified live.
### Core modules
- [x] `state.rs` — JSON sidecar, atomic temp + fsync + rename; records each aggregate's
resolved target endpoint id **and** backend type, plus per-Google-endpoint cursors
- [x] `vdir.rs` — read and write vdir directories, keyed by the UID inside each file
rather than by filename, since pimsync names files its own way
- [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: pull, reconcile, push
- [x] `retarget.rs` — moving an aggregate to a different target, deliberately
- [x] `pimsync.rs` — generate `pimsync.conf` (with `on_empty skip` and `on_delete skip`),
drive one-shot `pimsync sync` bracketing the reconcile step
- [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
- [x] `google/convert.rs` — iCalendar both ways, including recurrence, timezones and alarms
- [x] `google/api.rs` — incremental pull by syncToken; push by import / update / delete
with notification suppressed
### Behaviour
- [x] **`events.import` gate** — settled from Google's own API discovery document:
`events.import` accepts no `sendUpdates` parameter at all, while `insert`,
`update` and `delete` all do, and it is documented as adding "a private copy of
an existing event". Confirmed live that attendees and alarms survive an import.
Creation goes through `import`; update and delete pass `sendUpdates=none`.
- [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, purge bounded by the derivation
- [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
- [x] Routing: a `@endpoint-id` line in the description, or a matching category, picks
which source a new aggregate event is filed under; a hint naming an invalid sink
is refused rather than redirected to the default
- [x] Alarms map both ways, including Google's minutes-before reminder model
- [x] `webcal` URLs may come from a command, for feeds whose address is itself a
credential — Google's secret iCal address being the case in point
- [x] `doctor` validates the generated pimsync config and each Google authorisation
- [x] `calcalist status` — endpoints, aggregates, how many events are mirrored, and the
exact `@marker` names an event can carry, so routing is discoverable without
opening the config file
- [x] Recurrence overrides are pushed to Google. Google addresses an exception
through the series rather than as an event of its own, so the master goes up
first and each override is then matched to its instance by original start
time and patched. An override matching no occurrence is counted, not forced.
- [x] A `TZID` now travels with the `VTIMEZONE` that defines it, derived from the
zone's own transition table as the yearly rule it implies. A zone that does
not shift gets one fixed observance.
- [x] One Google authorisation covers every endpoint on that account. Tokens are
keyed by the account, discovered at login from the primary calendar's id —
which needs no scope beyond the calendar one already granted. An endpoint
names its `account` only when calcalist is logged in to more than one.
- [x] A Google endpoint that cannot be reached no longer ends the cycle. It is
named, only the aggregates depending on it stand down, and the run exits
non-zero so a lapsed token cannot pass for success.
- [x] `calcalist prune` — reports local mirrors of endpoints the configuration no
longer names, and removes them under `--force`
- [x] `--dry-run` pulls for real, into a throwaway copy of the local mirrors and
through a pimsync configuration that only ever reads from a server. What it
reports is measured against the calendars as they are now, and nothing
outside the copy is written.
### 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
- [x] `convert` against real captured API payloads, including the case where Google's
offset and its `timeZone` disagree
- [x] Topologies: a source feeding several aggregates, several aggregates sharing one
target, a cycle between two aggregates, a delete cascading across aggregates,
and competing edits arriving through two aggregates at once
- [x] Retarget: drift makes `sync` exit non-zero having written nothing and losing no
source event; an unrouted creation reaches a sink first
- [x] Safety (unit): no live `ATTENDEE`/`ORGANIZER` on a CalDAV-targeted mirror,
`VALARM` intact, `PARTSTAT: DECLINED` maps to `TRANSP: TRANSPARENT`
### Verified live, end to end
Against real accounts — two Google calendars aggregating into a Posteo CalDAV
calendar:
- Fan-in from both Google sources, each mirror tagged with its origin
- A public iCal feed synced through pimsync and pushed to CalDAV; idempotent across
repeated runs and correctly incremental
- An edit made in the aggregate reaching the originating Google calendar, and the
revert travelling the same way
- An event created in the aggregate routed to a chosen source by its description
marker, with the marker stripped before it arrived
- A deletion in the aggregate propagating through to Google
- The mass-deletion guard refusing a 100% removal until `--force`
- `pimsync check` validating the generated config against a live CalDAV server
### Integration
- [x] Against a real Radicale server and a real iCal feed, over pimsync: two
sources converge on a CalDAV target, and the next cycle is a no-op.
- [x] Safety, against the same server: no live `ATTENDEE` or `ORGANIZER` reaches
the aggregate, the guest list survives as inert data, the alarm survives
intact, and the source keeps its scheduling properties.
The plan asked for an SMTP sink here. Radicale implements no RFC 6638
scheduling, so a quiet SMTP port would have proved nothing about the
transform — no server in reach of a test sends calendar mail at all. What
is asserted instead is the bytes that reached the server, which is the
thing the transform is actually responsible for. Proving the Google half
was settled separately, from the API discovery document.
- [x] `--dry-run` reaches the servers, reports what it found, and leaves both the
target and the local mirrors untouched.
- [x] A retired endpoint's mirror is reported by `prune` and removed under
`--force`.
## M2 — packaging (done)
- [x] Run lock (`lock.rs`) — `flock` on the state directory, held by `sync`,
`aggregate retarget` and `prune --force`. Not a UI feature: nothing
previously stopped a timer firing into a hand-run cycle, and two cycles
interleaving writes over the same vdirs is what the design otherwise
avoids. The kernel releases it however the process ends, so a crash
cannot leave a lock to clear by hand; the refusal names the holding pid.
- [x] systemd user units in `systemd/``calcalist.service` (oneshot) and
`calcalist.timer`, with `RandomizedDelaySec` so installations do not all
call Google on the quarter hour, and no filesystem or IPC sandboxing
because the secret commands need the session keyring over D-Bus.
- [x] `calcalist discover` — asks a CalDAV server which calendars it has and
prints a ready-to-paste `[[endpoint]]` block for each. The `url` field is
the most error-prone thing in the config, and providers rarely show it.
- [x] `serve` removed from the CLI, and with it the last `unimplemented`
command, so every command the binary advertises now does something.
### The web interface, dropped
Specified from the start and designed in full before being dropped. The
reasoning, so it is not rediscovered as an oversight:
- Its audience would be people who find TOML hard. But with bring-your-own
OAuth client settled, every user must first create a Google Cloud project,
configure a consent screen and put a secret in a keyring — a far higher bar
than editing thirty lines of config. Anyone who clears it can edit the file;
anyone who cannot never reaches the file.
- The cost was three dependencies, five modules, an `auth.rs` refactor and a
security surface (token, `Host` validation, CSRF, secrets through subprocess
stdin) guarding something that reads the config and touches the keyring —
against SPECS.md's own "no speculative features or dependencies".
- `doctor`, `status` and `discover` had already absorbed what it was for.
The one idea worth keeping from the design was collection discovery, which
needed no web layer at all.
## Known gaps
- [ ] A `pimsync sync` that fails takes the whole CalDAV leg with it. Unlike the
Google side this cannot be narrowed: pimsync is one process covering every
pair, so a failure does not say which pair it belongs to.