Skip to content

Exchange: ask before a save drops a series' changed occurrences, and delete its deleted ones again - #123

Open
Timtam wants to merge 5 commits into
mainfrom
feat/ews-warn-exception-loss
Open

Timtam wants to merge 5 commits into
mainfrom
feat/ews-warn-exception-loss

Conversation

@Timtam

@Timtam Timtam commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Decision 243, part A: before a save makes Exchange drop occurrences of a series the user changed or deleted on their own, Aperio asks. Deleted occurrences it deletes again afterwards, so only the changed ones are lost. Decisions 246-253 (Toni, 2026-10-11).

Why

Measured on Exchange 2019 (the zone-first live test and round 6):

  • Writing a series master's Start and End drops every changed and deleted occurrence: a move (M3), a zone switch (L3a, L3b), with or without the rule (M6).
  • So does a new pattern under the same start: Mondays and Wednesdays (P1), every other week (P2).
  • A title (M4), the COUNT (M5), an end date instead, no end or an earlier end (round 8, U1-U3) and the same zone written again (N3) keep them.
  • After the drop, DeleteItem on OccurrenceItemId by index deletes an occurrence again at its moved slot (R1 +2 h, R2 +1 day with the weekday shifted, R3 a zone switch).

Until now a move of such a series lost them silently; #122 only refused a zone-only rewrite (245).

What changes

Core (cal-core)

  • Event::accepts_exception_loss: the transient consent, sent only on the write repeated after the question.
  • Event::deletions_not_restored: the transient result naming deleted occurrences that came back and could not be deleted again.
  • SeriesRewrite (slot, zone, pattern) and the refusal detail {rewrite}:{changed}:{deleted} (exceptions-would-be-lost: slot:2:1).

Exchange adapter

  • The update builder plans what an update rewrites (UpdatePlan::rewrite): Start and End, only the zone's clock, or the pattern (the rule's pattern element changes, the range does not count). None for a title, a range change, a rule removed, an exception, or a single made a series.
  • It also plans where a deleted occurrence stands afterwards (Placement): Shifted where the written rule is the server's moved with its first day as cal_core::shift_series moves it (a move, a zone switch, the weekday shifted with a day move), compared without a week start that changes no day, which the editor leaves out where a drag moves it along; Same under a new pattern with the same slot; Unknown where pattern and slot change together, or where a clock the restore needs cannot be read.
  • The write path counts from the fresh copy: changed occurrences, plus deleted ones it cannot place. Without consent and with anything to lose, it sends nothing and refuses with the detail (Forbidden). The 245 refusal is now this question (247).
  • After the update it deletes each placeable deleted occurrence again: the index the new rule gives, that index and its neighbours read back, and only an occurrence whose start is exactly the expected one is deleted — with a cancellation where the update told the attendees, without one otherwise. Nothing came back only where the series, read index by index, steps over the place (from an occurrence before it, or the series' start, to one after it, or its end), or where the rule's own index is a deletion Exchange kept; an index just deleted again stands for the occurrence it held. Anything else — an occurrence within half a day of the place that is not the expected one, a probe that cannot be read, a failed delete — is returned in deletions_not_restored, where it now stands (an all-day day anchored as the read anchors it; where the series cannot be read again, moved as the update moved it, on the kind and clock the update left). The master's ChangeKey is read again afterwards. The consent never rides back on the returned event.
  • A series head whose copy cannot be read is not written (248, copy-unreadable); singles keep the blind write.

Surfaces (desktop + phone)

  • shared/exceptionsLoss.ts: reads the refusal, builds the question (what is rewritten, what is lost, with counts and plurals) and the notice.
  • Only the series' own save asks; a split's cut refused this way says the sentence, as a yes would create the new series twice.
  • Desktop editor: a ConfirmDialog over the form, focus on Cancel. "Trotzdem speichern" calls the form's submit again directly, with the consent as an argument to that one call; the form's own submit never carries it. Deleted occurrences that came back are named in the focused notice before the editor goes on.
  • Phone editor: the same, through the shared scope dialog, and the same notice.
  • Drag (Day, Week, Month) and the desktop carry dialog say the translated sentence instead of the raw token; they ask in PR B (250, 251). The phone carry modal now composes the same sentence. Both carry dialogs and the drag name deleted occurrences that came back and could not be deleted again.

Tests

  • Writer plan (each_save_says_what_it_rewrites_and_where_deleted_ones_stand): an hour later, a day later with Tuesdays, a day later still Mondays, longer, Mondays and Wednesdays, every other Monday, five times, until the 16th, renamed, no longer a series; an exception; a single made a series. A day later with a week start: every other Monday to every other Tuesday as the editor writes it and as a drag writes it, weeks from Sunday as the editor writes it (all placed), and every other Monday and Sunday without the week start it needs (not placed). The zone switch (New York, Tokyo, Paris; Vienna) and the end-zone shape plan a zone rewrite.
  • Through the adapter, with a routed mock server: a zone switch with a changed occurrence asks (zone:1:0, nothing sent) and goes out with consent, the consent not on the result; a move with only a deleted occurrence re-deletes the third at its moved time and reads the ChangeKey again, and with mismatching occurrences deletes nothing and names the slot; a series without a readable copy is refused.
  • More through the adapter: a notified move cancels the re-deleted occurrence; a new pattern deletes again only what it still has (an occurrence it has, one past the new end, one whose probe cannot be read); a stored zone that cannot be read plans no placement.
  • Where a deleted occurrence does not stand exactly where expected: an hour off next to one just deleted again, and as the last of the series, is named; a deletion Exchange kept at the rule's index is not. A new pattern without the start's weekday names nothing (master start kept or moved). An index deleted again still says where its occurrence stood. Where the series cannot be read again, the named day follows the kind the update gave it (all-day to 14:00, 9:00 New York to all-day).
  • cal-core: the detail round-trips; malformed details are none.
  • Surfaces: the shared helper in German and English, the write-error fallback (desktop and phone), the desktop editor (ask and resend with consent and nothing else changed; cancel sends nothing; a resubmit the form stops leaves no consent for the next save; yes works where the web view has no requestSubmit; the notice names the day and the editor goes on after it), a split's refused cut says and does not ask, and the drag hears the saved series.
  • Red proofs: twenty-eight, each failing at least one test: in the adapter, never asks; ignores the consent; consent rides back; no re-delete; re-delete unconfirmed; blind series write; no pattern rewrite; never shifted; no cancellation on a notified re-delete; an absent place counted as came back; an unread probe ignored; shifted without readable clocks; the restore as round one left it; the fallback always, or never, anchors a day; a kept deletion counted as came back; an index deleted again counted as a kept deletion; the series' start not a side; a week start always counts; a week start never counts. In the surfaces: no consent on the resend; no question; no notice; the split's cut asks; the drag never hears the saved series; the phone's write error ignores the loss; the consent outlives a stopped resubmit; the yes goes through the form's submit.
  • Gates: fmt, workspace clippy -D warnings, cargo test --workspace, both live-test generators, cargo xtask ts-types (binding copied), vitest (2390), lint, tsc, mobile tsc, check:bindings, docs build and check:links.

Live test

Round 7, Aperio's own requests on Toni's Exchange 2019 at 9a1d34d, all as meant:

  • Q1 — a W. Europe weekly series with a deleted (2.11.) and a moved (9.11.) occurrence, two hours later, no consent: refused slot:1:0, nothing sent, both exceptions kept.
  • Q2 — the same with consent: Aperio sent the zone and the slot; the series stands at 12:00, 9.11. is like the series again (lost, as asked), 2.11. is deleted again at 12:00, nothing named as not restored.
  • Q3 — only the deleted occurrence, a day later with Tuesdays: no question; zone, slot and rule sent; 3.11. deleted again.
  • Q4 — only the deleted occurrence, Mondays and Wednesdays, eight times: no question; zone and rule sent; 2.11. still deleted, the other seven shown.
  • Q5 — the moved occurrence, every other week: refused pattern:1:0, nothing sent, both exceptions kept.
  • Q6 — title only: only item:Subject sent, nothing deleted, both exceptions kept.

Round 8, the range only, on the same series shape (Aperio plans no rewrite there and asked nothing): an end date instead of four times (U1), no end (U2), an earlier end that keeps the moved occurrence (U3) — Aperio sent the zone and the rule; both exceptions kept every time.

First check (fixed in a5e7b34)

Twenty-one findings were confirmed, nine distinct:

  • A notified update re-deleted without a cancellation: attendees, whose series had the occurrence again, were never told. Now cancelled.
  • "Save anyway" on a split's cut could not work: the consent never reached the cut, and a yes recreated the new series. The split now says the sentence instead of asking.
  • Shifted was promised where a clock could not be read, so deleted occurrences came back unasked. Now Unknown there, and asked.
  • The restore named occurrences that never came back (past a new end, or kept deleted), and stayed silent where a probe could not be read. Probes now tell absent from unreadable.
  • The notice could name the wrong day (raw all-day midnights; old instants when the series could not be read again). Fixed.
  • Drag and carry dropped deletions_not_restored; the phone carry said the loss without counts. Both fixed.
  • Docs: "an end keeps them" overclaimed one measured cut (round 8 now measures it); stale comments about the blind series write and the 245 refusal; the guides' split hint.

Second check (fixed in a5102bb)

Twelve findings were confirmed:

  • The restore hid occurrences that came back: a "deleted" or "past the end" answer at any probed index silenced the deletion, so a neighbour just deleted again, or the index after the last occurrence, hid one that came back at another time (a gap of the clock). Now only a series that steps over the place, or a kept deletion at the rule's own index, means nothing came back.
  • A new pattern without the start's weekday named the deleted start: the series' start now counts as a side.
  • Where the series could not be read again, the day followed the old kind (all-day on or off): now the kind and clock the update left.
  • The consent could outlive a resubmit the form stopped and ride a later save unasked: the desktop withdraws it right after the resubmit, the phone hands it to that one save.
  • Docs: the guides said every split or delete from an occurrence on is refused, that count and end never ask, and missed the unreadable zone; "how often" stood for the range; TODO and DESIGN said every deleted occurrence is asked about; the SeriesRewrite bindings were stale; three comments were stale.

The phone fix has no test of its own (no phone editor tests exist); its types are checked.

Third check (fixed in f27e5ef)

Three findings were confirmed (five reports):

  • A whole-series day move from the editor was planned Unknown for every other week on one weekday, or weeks from Sunday: the editor leaves out a week start that changes no day, shift_series keeps it, so the patterns differed. Deleted occurrences were asked about and left back, where a drag deleted them again. Now compared without such a week start.
  • "Save anyway" depended on requestSubmit, which WebKit before Safari 16 lacks: the yes did nothing and the consent stayed for the next save. Now the submit is called directly with the consent as an argument.
  • The guides promised the question for a change or delete from a later appointment on a zone-rewrite series, which is refused with the sentence; ews.md said the same. Fixed in all three.

Docs

  • ews.md: "A save that would drop a series' exceptions asks first", "A series is never written blind".
  • DESIGN-series-time-zone.md: 243 (part A built), 245 as a question, 246-253 with round 6, the Exchange paragraph, risks, open questions.
  • TODO: 243 ↻, PR B 🚩.
  • Troubleshooting de/en: "Aperio asks whether occurrences of a series may be lost", rewritten for the question.
  • Tutorial de/en: the note on moving a whole series with changed or deleted occurrences.

The phone needs a fresh .so and XCFramework.

🤖 Generated with Claude Code

Timtam and others added 5 commits October 11, 2026 01:29
…nd delete its deleted ones again

Decisions 243 and 246-253, the core and the Exchange adapter (PR A, part 1).

- cal-core: `Event::accepts_exception_loss`, the transient consent the
  surfaces send after asking; `Event::deletions_not_restored`, the transient
  result naming deleted occurrences that came back and could not be deleted
  again; `SeriesRewrite` (slot, zone, pattern) with the refusal's detail
  `{rewrite}:{lost}`.
- adapter-ews: the update builder plans what it rewrites (start and end, the
  zone's clock, the pattern; a range change, a title or a rule removed rewrite
  nothing) and where a deleted occurrence stands again: shifted with the
  series (live round 6, R1-R3), at its instant under a new pattern, or not
  placeable. The 245 refusal leaves the pure builder.
- The write path counts from the fresh copy what would be lost (changed
  occurrences, and deleted ones it cannot place) and, without consent, refuses
  `exceptions-would-be-lost: {rewrite}:{lost}`; nothing is sent. With consent,
  or with only placeable deleted occurrences, it writes, then deletes each
  deleted occurrence again where it now stands, confirmed by its start; any
  that cannot be confirmed is named on the returned event. The consent never
  rides back on it.
- A series head is never written blind (248): an unreadable copy refuses
  `copy-unreadable`.

Tests: the plan for every kind of save; through the adapter the question, the
consent, the re-delete (and its failure), and the blind refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… phone

Decisions 243 and 246-253, the surfaces (PR A, part 2).

- The refusal's detail names both counts, `{rewrite}:{changed}:{deleted}`, so
  the question says exactly what happens: a changed occurrence takes the
  series' details again, a deleted one comes back (252).
- shared/exceptionsLoss.ts reads the refusal and builds the question both
  editors ask, and the sentence naming deleted occurrences that came back.
- Desktop editor: the refusal opens a ConfirmDialog over the form, focus on
  Cancel; "Trotzdem speichern" submits the same form again with
  `accepts_exception_loss` on that one write. Deleted occurrences that could
  not be deleted again are named in the focused notice before the editor goes
  on (the split notice's pattern).
- Phone editor: the same through the shared scope dialog, and the same notice.
- Dragging a series and the carry dialog say the translated sentence instead
  of the raw token (they ask in PR B, decisions 250, 251).
- Locales de/en; the fallback sentence for paths that cannot ask.
- Docs: ews.md (the question, the re-delete, no blind series), DESIGN
  (243 status, 245 as a question, 246-253 with round 6), TODO, both
  troubleshooting guides (the question explained), the tutorial's note on
  moving a series with changed occurrences.

Tests: the shared helper (parse, both languages, the notice), the write-error
fallback, and the desktop editor (ask and resend with consent, cancel sends
nothing, the notice). Red proofs: three in the editor, eight in the adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat can be placed, name the right day

- A re-delete after an update that told the attendees sends them the
  cancellation: the series they were sent has the occurrence again.
- `Placement::Shifted` only where both clocks can give the wall time the
  restore finds an occurrence by; otherwise the deleted ones are counted in
  the question instead of coming back unasked.
- The restore reads each probe as an occurrence, as absent (Exchange says
  deleted or past the end: nothing came back), or as unreadable (nothing
  proven: the occurrence is named). It reports where an occurrence now
  stands, as the read anchors an all-day day, also when the series cannot be
  read again (moved by the start the update wrote).
- Only the series' own save asks. A split's cut refused the same way says the
  sentence: a yes there would create the new series a second time.
- Drag (Day, Week, Month) and both carry dialogs name deleted occurrences that
  came back and could not be deleted again; the phone's write errors compose
  the loss sentence as the desktop's do.
- Round 8 measured the range: an end date instead of a count, no end and an
  earlier end keep changed and deleted occurrences (U1-U3), as COUNT did (M5).
  Docs and comments cite it; DESIGN 245 and TODO no longer speak of a refusal;
  the guides say a split or delete from an occurrence on does not ask.

Tests: a notified move cancels; a new pattern deletes again only what it
still has (absent and unreadable probes); an unreadable stored zone cannot be
placed; the split's cut says, not asks; the drag hears the saved series; the
phone's text. Seven red proofs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t for one save only

The restore after a rewrite decided "nothing came back" too readily and,
in two places, the opposite:
- Any probe that answered "deleted" or "past the end" silenced the
  deletion, though that answer speaks only for its own index. A neighbour
  the restore had just deleted again, or the index after a series' last
  occurrence, hid an occurrence that did come back at another time (a
  gap of the clock). Now nothing came back only where the series, read
  index by index, steps over the place (from an occurrence before it, or
  the series' start, to one after it, or its end), or where the rule's
  own index is a deletion Exchange kept; an index deleted again stands
  for the occurrence it held, and an occurrence within half a day of the
  place that is not the expected one is always named.
- A new pattern without the start's own weekday named the deleted start
  as "came back"; the series' start now counts as a side, so a place
  before the first occurrence brought nothing back.
- Where the series could not be read again, the day was anchored on the
  kind the series had before the update; now on the kind and the clock
  the update left it on (`UpdatePlan::written_all_day`, `written_clock`).

The desktop editor withdraws the consent right after the resubmit it
starts, so a resubmit the form's own validation stops leaves none for a
later save; the phone hands the consent to that one save as an argument.

Docs: the troubleshooting guides (de/en) no longer say every split or
delete from an occurrence on is refused, say when count and end bring
the zone question, and name the unreadable zone; "how often" is now the
pattern's frequency and "how many times" the range (SeriesRewrite,
pattern_of; bindings regenerated); the TODO and DESIGN summaries say the
question names what would be lost; DESIGN 253 and ews.md describe the
new restore rule; stale comments fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thout requestSubmit

A whole-series day move from either editor writes the rule through
begin_series_anew, which leaves out a week start that changes no day;
the plan compared it with shift_series' rule, which keeps one, so every
other Monday moved to Tuesday (or a weekly series with weeks from Sunday)
was planned Unknown: its deleted occurrences were asked about and then
left back, where a drag of the same series deleted them again. The plan
now compares both patterns without a FirstDayOfWeek that changes no day
(cal_core::series_shift::week_start_matters, now public); one that does
change a day still tells two series apart.

The desktop's "Save anyway" called the form's requestSubmit with the
consent in a ref; on a web view without it (WebKit before Safari 16) the
call threw, nothing was sent and the consent stayed for the next save.
The yes now calls the submit directly with the consent as an argument,
as the phone does; the form's own submit never carries it.

Docs: the troubleshooting guides (de/en) no longer promise the question
for a change or delete from a later appointment on, which is refused with
the sentence; ews.md says the same, and it and DESIGN 253 name the week
start the editor leaves out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant