Skip to content

Split a series by creating the new part first - #97

Merged
Timtam merged 3 commits into
mainfrom
fix/split-create-first
Sep 26, 2026
Merged

Timtam merged 3 commits into
mainfrom
fix/split-create-first

Conversation

@Timtam

@Timtam Timtam commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Second PR of the arc "exceptions when splitting a series" (decision 134), decisions 136, 144 and 145.

Why

"This and all following" truncated the series, then created the new part (the tail), and put the head back if the create failed. Putting it back restored only the rule. The truncate had already dropped the changed and deleted occurrences after the cut at the provider: Google deletes those instances, and CalDAV writes the series without them. They stayed dropped, with nothing said. The host's calendar move solved the same problem the other way round: create first, so a half-failed move never loses data.

The change

Create first, then truncate (136). writeSeriesSplit now creates the tail, then truncates the head. If the truncate fails, the question is whether it certainly changed nothing (144):

  • Refused. A refusal token, or one of the codes conflict, forbidden, invalid_input, not_found, auth, unsupported. The tail is deleted again with the create's notify flag: an exact undo, and the refusal is reported as before. writeNeverLanded in shared/eventWriteError.ts decides this.
  • Unsure. Network, protocol, or no code at all. The truncate may have reached the provider before its answer was lost, and deleting the tail then would leave the series ending at the cutoff. Both stay.

An unsure cut is a save with a warning (145). The tail exists, so throwing would invite a retry, and a retry writes the tail a second time. writeSeriesSplit returns { tail, headCut: 'unsure', failure } instead:

  • The editors give the tail its private reminders and colour, close as after a save, and announce that the series may show twice from the day of the cut, naming what went wrong.
  • The carry dialogs count the copy and group its new part. They stay open with one note per such copy and no "Try the rest again", because closing would take the only words that name it.

If the undo fails too (refused, then the delete of the tail fails), it stays a failure, marked seriesMaybeShownTwice:

  • The editor says the series may show twice and asks to check the calendar before saving again.
  • The carries say it per copy and do not offer that copy again.

The day of the cut is written by cutoffDay in the reader's language, on the day the occurrence is shown.

Callers. Both editors and both carry dialogs, desktop and phone in parity. seriesLeftTruncated and its sentence are gone. Four new sentences, in EN and DE.

Docs. The tutorial (en and de) says what happens when ending the old series fails. TODO.md records the change.

Checks

  • Desktop and mobile tsc, eslint clean.
  • vitest: 2186 passed, both locally and under TZ=UTC.
  • New or rewritten tests:
    • writeSeriesSplit:
      • the tail is created before the truncate;
      • nothing more is written when the create fails;
      • refusals (three codes and a token under a network code) delete the tail with the create's value;
      • unsure failures (network, protocol, internal, a codeless error, a string) keep both and return headCut: 'unsure' with the failure;
      • a failed delete is marked;
      • a refusal that is no object is still marked.
    • writeNeverLanded: every refusing code on both surfaces; tokens whatever the code; network, protocol, internal and codeless errors never count.
    • cutoffDay: local day at half past midnight, in German and English; unreadable input kept.
    • Desktop editor:
      • the create comes before the update;
      • a conflict deletes the tail with the create's notify flag and says the conflict;
      • a network failure is announced as changed-but-maybe-twice with title, day and detail, without an error on the form;
      • a failed delete is said, with the request to check before saving again.
    • Desktop carry:
      • the create comes before the update;
      • a conflict deletes the copy's new part silently;
      • a network failure names calendar and day, groups the new part, stays open and offers nothing to retry;
      • a copy whose new part could not be deleted is not offered again.
  • Red proofs, 16 of 16 red, tree restored byte for byte:
    • the old order;
    • deleting the tail whatever the failure, and never deleting it;
    • a failed delete left unmarked;
    • a network failure taken as a refusal;
    • refusal tokens ignored;
    • both undos notifying attendees;
    • the editor saying plain "changed" after an unsure cut;
    • the carry not naming the day;
    • the day read in UTC;
    • a primitive refusal left unmarked;
    • the carry offering a retry after every copy is written;
    • a copy with a failed undo offered again;
    • the editor not saying the undo failed;
    • the unsure copy not grouped.
  • Not covered by a test: the phone editor and phone carry (no runner); they mirror the desktop line for line.

Review round

One adversarial round (3 lenses: provider errors, logic and parity, screen reader; every finding verified). 21 confirmed, several of them the same finding from two lenses; 3 refuted. Fixed here:

  • CalDAV: a cut that went through came back as a conflict, and the new series was deleted (medium). send_retrying replays after a connection that died once the request was out, and the server may already have applied the first attempt. The replayed guarded PUT then gets 412, which read as conflict, a certain refusal, so the new series was deleted around a truncate that had landed. send_retrying_marked now says whether it replayed. A 412 on a replayed guarded PUT is a network error ("it may have been saved"), and a 404 on a replayed DELETE counts as deleted: the undo of a split no longer reports a tail that is gone as still there. The http.rs doc said the server "demonstrably never processed" such a request; corrected.
  • Exchange: creating first dropped decision 106's proof (medium). The create invalidated the calendar, so the truncate right after it kept no fields and wrote this device's copy of the master back over another device's change. CacheStore::invalidate_after_create keeps the proof, because a create changes no row the cache holds. It keeps it only if no other write began or ended meanwhile. Both hosts use it for creates.
  • The warning after an unsure cut was an announcement only (medium, decision 146). Sighted users never saw it, and the carry dialog opening next spoke over it. The editor now stays open in place of the form: a focused warning note on screen and only Close, which then goes on to the carry offer or closes. Desktop and phone alike.
  • Phone carry, screen reader (medium). The outcome line's assertive live region cut off the announcement that carried the doubts on Android; it is no longer a live region, because every outcome is announced as it is set. When the outcome 'written' removes the button that held the cursor, the outcome line takes focus and the doubts are said after it, queued.
  • The failed-undo sentence said "Nothing was changed" in the middle (low). eventWriteFailureReason gives the refusal's reason alone, with new reason sentences in EN and DE.
  • Words:
    • an EventDialog comment still described the old order;
    • the 'written' outcome's comments now cover a copy whose undo failed;
    • the tutorial (en and de) describes the failed undo, the note in the editor, and the carry's notes without a retry;
    • a stale TODO entry named the removed seriesLeftTruncated.
  • Tests:
    • the editor's undo carries the notify flag (a meeting calendar, sendCancellations: true);
    • the unsure editor path keeps a focused note, writes the tail's colour, shows no Save, and closes only on Close;
    • the failed-undo sentence names the reason without "nothing was changed";
    • the carry's announcement holds the count and the doubt once;
    • the 'written' outcome counts the copy;
    • eventWriteFailureReason;
    • CalDAV: a 412 on a replayed PUT is unsure, a 412 without replay is a conflict, a 404 on a replayed DELETE is deleted;
    • host-core: a create keeps the proof, and proves nothing new.

Deferred (decision 147, the next PR): certain refusals that arrive as protocol make both series stay with the warning although nothing was cut. That covers EWS fault answers, Google and Graph HTTP 400 and 429, and CalDAV's own "nothing was saved" checks. The adapters will mark them server-refused. The same PR takes Graph's DELETE after a successful /cancel, which may answer 404 and so reports the undo as failed. Both are in TODO.md.

Not covered by a test: the one-line use of invalidate_after_create on each host. Neither host's create command has a harness with an external calendar.

Checks for the round:

  • Rust: 2847 passed; fmt and clippy -D warnings clean; adapter-caldav and host-core alone clean.
  • TypeScript: desktop and mobile tsc and eslint clean; vitest 2189 passed, locally and under TZ=UTC.
  • Red proofs for the round, 10 of 10 red, tree restored byte for byte:
    • a 412 on a replayed PUT read as a conflict;
    • a 404 on a replayed DELETE read as not here;
    • a create dropping the proof;
    • a create proving a calendar never read;
    • the editor closing after an unsure cut;
    • the notice not focused;
    • the failed undo saying nothing was changed;
    • the editor's undo telling nobody;
    • the carry announcing without its doubts;
    • a conflict's reason saying nothing was changed.

Second review (of the review round)

Two lenses (Rust; UI): 10 confirmed, 4 refuted. All fixed here:

  • CalDAV, medium: the replayed-404-is-deleted fix stopped the home-set walk early. A bare id names a different URL in every calendar, so a replayed 404 in a calendar that never held the event ended the walk. The calendar that did hold it got no DELETE, and a split's new series was reported deleted while it stood. A replayed 404 is now its own outcome, GoneOnReplay. The walk goes on past it and counts it as done only when no calendar holds the event and none failed. DeleteWalk holds this rule for both the event walker and the task walker.
  • CalDAV, low: a connect failure sent nothing, so its replay's answer is its own and no longer marked as "may have landed". A real 412 after a failed connect stays a conflict.
  • Phone editor, medium: the header's Cancel, the swipe and Android's back button closed the notice without going on to the carry offer; the desktop's Escape and × went on. While the notice is up, the header button is Close, the swipe is off, and beforeRemove sends every way out through the same handler.
  • Both editors, medium: leaving the editor while the split saved lost the warning entirely. A notice for an editor that is gone is announced instead, and nothing navigates from it.
  • Phone carry, medium, twice: a queued announcement is queued on iOS only, and the error line's assertive live region cut off the outcome on Android. Every outcome now puts the cursor on the outcome line. The line's label reads the outcome, the last failure and this pass's doubts in one utterance. The error line is no longer live, and the iOS-only error announcement is gone.
  • Low: closing the notice brought the form back, with a live Save, while the carry lookup ran. The notice now stays until the editor goes away, and it goes on only once.
  • Words:
    • a test doc had been spliced onto the wrong test;
    • http.rs still said "never processed" in one place, and the module doc now says what replaying is safe for, naming MKCALENDAR's 405 on replay (a known, older gap, now in TODO.md).
  • Test: Escape on the notice goes on to the carry offer, once, and the notice stays in the meantime.

Checks for the second round:

  • Rust: 2850 passed; fmt and clippy -D warnings clean.

  • TypeScript: desktop and mobile tsc and eslint clean; vitest 2190 passed, locally and under TZ=UTC.

  • New tests: the walk goes on past a replayed 404; a replayed 404 found nowhere else is gone, but a failure elsewhere wins; only a request that went out may have landed; the Escape test above.

  • Red proofs, 6 of 6 red, tree restored byte for byte:

    • a replayed 404 ending the walk;
    • a replayed 404 found nowhere else read as an error;
    • a connect failure counted as landed;
    • a replayed 404 read as not here;
    • Escape on the notice closing without going on;
    • the notice going on twice.

    (A first run of the previous round's proofs hung on a break of mine that locked the same mutex twice. The tree was restored from the backup and checked; that proof was rewritten and is red.)

Worth knowing

  • Mails. With notifications on, attendees now get the new series' invitation before the old series' update. A refused truncate sends them the invitation and then its cancellation.
  • Phone refusals. On the phone, invalid_input, not_found, auth and unsupported arrive without their code (TODO B9). A truncate refused for one of those reasons counts as unsure there: both stay, with the warning, rather than the new series being deleted.

🤖 Generated with Claude Code

Timtam and others added 3 commits September 25, 2026 23:51
"This and all following" truncated the series, then created the tail, and put
the head back if the create failed. Putting it back restored only the rule:
the changed and deleted occurrences the truncate had dropped at the provider
(Google deletes them, CalDAV no longer writes them) stayed dropped.

`writeSeriesSplit` now creates the tail first and truncates after (decision
136). If the truncate fails, what happens depends on whether it certainly
changed nothing (decision 144):

- Refused (a refusal token, or `conflict`, `forbidden`, `invalid_input`,
  `not_found`, `auth`, `unsupported`): the tail is deleted again with the
  create's notify flag, an exact undo, and the refusal is reported. If that
  delete fails too, the failure is marked (`seriesMaybeShownTwice`).
- Anything else (network, protocol, no code) may have reached the provider,
  and deleting the tail then would leave the series ending at the cutoff. Both
  stay, and the split counts as written (decision 145): it returns
  `headCut: 'unsure'` with the failure, instead of throwing a failure a retry
  would repeat by writing the tail twice.

`writeNeverLanded` in eventWriteError.ts says which failures are refusals; on
the phone only `forbidden`, `conflict` and `network` carry their code (B9), so
every other refusal counts as unsure there.

The four callers follow. An unsure cut is a save: the editors give the tail
its private reminders and colour and announce that the series may show twice
from the day of the cut, with what went wrong. The carry dialogs count the
copy, group its new part, and stay open with a note per such copy, without
"try again". A tail that could not be deleted again is said the same way:
the editors ask to check before saving again, the carries do not offer the
copy again. `cutoffDay` writes the day in the reader's language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CalDAV: `send_retrying` replays after a connection that died once the
  request was out, and the server may have applied the first attempt. The
  replay of a guarded PUT then got 412, read as a conflict, so a split
  deleted its new series around a truncate that had landed.
  `send_retrying_marked` says whether it replayed: a 412 on a replayed
  guarded PUT is now a network error ("it may have been saved"), and a 404
  on a replayed DELETE counts as deleted. The http.rs doc no longer claims
  such a request was never processed.
- Creating first dropped decision 106's proof: the create invalidated the
  calendar, so the truncate right after kept no fields, and on Exchange it
  wrote this device's copy of the master over another device's change.
  `CacheStore::invalidate_after_create` keeps the proof (a create changes
  no cached row) unless another write began or ended meanwhile; both hosts
  use it for creates.
- Decision 146: after an unsure cut the editor stays open in place of the
  form, with the warning focused on screen and only Close, which then goes
  on to the carry offer or closes. An announcement alone was never seen by
  sighted users, and the carry dialog opening next spoke over it.
- Phone carry: the outcome line is no longer an assertive live region (on
  Android it cut off the announcement with the doubts); when the outcome
  'written' removes the button that held the cursor, the outcome line takes
  focus and the doubts are said after it, queued.
- `eventWriteFailureReason` gives a failure's reason without "Nothing was
  changed", for the sentence that says a split's new series could not be
  taken back.
- Comments, the tutorial (en, de) and TODO.md follow; decision 147 (certain
  refusals marked by the adapters, Graph's DELETE after /cancel) is noted
  for the next PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CalDAV: a replayed 404 counted as deleted, but a bare id names a
  different URL in every calendar, so the home-set walk stopped in a
  calendar that never held the event, and the one that did got no DELETE:
  a split's new series was reported deleted while it stood. A replayed 404
  is now `GoneOnReplay`; `DeleteWalk`, used by the event and task walkers,
  goes on past it and counts it as done only when no calendar holds the
  event and none failed.
- A connect failure sent nothing, so its replay's answer is its own:
  `first_attempt_may_have_landed` is false for it, and a real 412 after a
  failed connect stays a conflict.
- Phone editor: while the split notice is up, the header button is Close,
  the swipe is off, and `beforeRemove` sends every way out through the one
  handler that goes on to the carry offer, as the desktop's Escape does.
- Both editors: a notice for an editor the user already left is announced
  instead, and nothing navigates from it. Closing the notice goes on once
  and keeps it on screen until the editor goes away, so the form never
  comes back with a live Save in between.
- Phone carry: every outcome puts the cursor on the outcome line, whose
  label reads the outcome, the last failure and this pass's doubts in one
  utterance; the error line is no longer a live region, which cut the
  outcome off on Android, and a queued announcement queued on iOS only.
- Words: a test doc spliced onto the wrong test; http.rs's "never
  processed" and what replaying is safe for, naming MKCALENDAR's 405 on
  replay (older, now in TODO.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam
Timtam merged commit 233dee0 into main Sep 26, 2026
13 checks passed
@Timtam
Timtam deleted the fix/split-create-first branch September 26, 2026 10:48
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