Skip to content

Exchange: zone first, the measured all-day order, no zone switch that drops exceptions - #122

Merged
Timtam merged 7 commits into
mainfrom
fix/ews-zone-before-slot
Oct 10, 2026
Merged

Timtam merged 7 commits into
mainfrom
fix/ews-zone-before-slot

Conversation

@Timtam

@Timtam Timtam commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

The fix the 8a live test called for (step 9): an Exchange update writes the zone first, the slot after it where the clock moves, and the rule last. An all-day series given a time writes the slot on both sides of the zone and the rule always, and a zone switch that would drop a series' exceptions is refused. Decisions 240-242, 244, 245.

Why

A new StartTimeZone keeps the item's stored wall clock and relabels it in the new zone:

  • Round 1, B2: Start and End written before a new zone moved the series (08:00Z became 01:00Z).
  • The 8a live test, step 9: a single Aperio created (stored in UTC), Monday 00:30 in Berlin, made weekly in one save. The update sent the rule and then the zone, without Start or End, and the series landed on Sundays at 23:30.

The update builder wrote the zone LAST. So every update that changed an Exchange item's clock moved it by the offset:

  • a single created in Aperio made a series (step 9);
  • an all-day series (stored without a zone) given a time of day;
  • a zone switch on a stored series, which keeps the instant (DESIGN, "Die Zone wechseln"; no editor offers it yet, stages 11 and 12, but the writer must be right for it): main sent the rule (rewritten on every zone switch, on the stored zone's day) and then the zone, without Start or End, and the item moved.

main behaves the same; this is not a regression of 8a.

What changes (adapter-ews, event_to_update_field_xml_in)

  • The zone goes first whenever it is written: when the series' zone, its all-day flag or its slot changes. The rule no longer opens that gate, which keeps the decision free of a cycle with the clock.
  • The slot follows a moving clock (240). Where the written zone names another clock than the stored one, on either boundary, Start, End and IsAllDayEvent are written after it even where they did not change. Clocks are compared as the read side maps an id (boundary_zone), so W. Europe Standard Time and a stored Europe/Berlin, or Vienna, which Exchange stores under the same id, are one clock. A stored zone Aperio cannot read, or a missing copy, counts as another clock. A slot written only for this is the server's (landed), so a boundary another device has moved and this edit kept is not put back (106).
  • The rule last, and only when it changes on the wire (241). It is built on the new zone's clock where the clock moves (on the stored zone's where it stays, 234) and written only when its built form differs from the server's. A zone change writes no rule while the series' first day and weekday stay the same on the new clock, and rewrites it where the switch moves them (near midnight). A missing copy still writes or deletes the rule, and an edited rule that no longer builds still fails the save.
  • A rule change is a change to the rule's text or zone. A copy that differs from the server only in a series' exceptions (a stale one) changes no rule: an update never writes exceptions, so it opens neither the zone nor the slot, and a title-only save from it writes the title alone.
  • The zone, and the rule's first day, are read from the slot the server keeps. Where the edit leaves the server's all-day flag and slot alone, no zone goes onto an all-day item (46a), and the rule starts on the server's day, not on this device's stale copy.
  • An all-day series given a time (244) writes IsAllDayEvent, Start and End, then the zone, then Start and End again, then the rule, always. On a daily series the zone first is refused whole (ErrorOccurrenceTimeSpanTooBig, L2; a weekly one took it, round 2 B4, and gets the same order); without the rule the series lands a day late (M1, M2); this shape lands right (M7, M8). ErrorOccurrenceTimeSpanTooBig is now a whole-request refusal (server-refused), so a split knows nothing landed.
  • A zone switch that would drop exceptions is refused (245). Exchange drops every changed and deleted occurrence when the master's Start and End are written (L3a, L3b, M3, M6). Where Start and End are written only because the clock moves, on a series that has any, nothing is sent: exceptions-would-be-lost: zone, carried as Forbidden, with its own sentence on both surfaces ("Diese Änderung würde die Zeitzone der Serie wechseln, und dabei verwirft Exchange ihre geänderten und gelöschten Vorkommen. Es wurde nichts geändert."). A save that moves the series writes them as main does; asking first there is decision 243, the next PR.

Creates are unchanged: CreateItem sets everything at once.

Tests

  • event_to_update_field_xml_in, Berlin device unless noted:
    • step 9's shape, in Berlin and in Tokyo: zone, slot, rule; StartDate on the new zone's Monday;
    • an all-day series (stored in UTC) given a time: flag, Start, End, zone, Start, End, rule; both slots the same instants; StartDate on Berlin's Monday; the same order for one Outlook stored in W. Europe, where the zone stays (O1's shape);
    • zone switch to New York on a W. Europe series: same day → zone and slot, no rule; across a day (with and without the weekday answer Sunday) → zone, slot, rule on Sunday;
    • a zone switch on a series with a deleted occurrence, to New York, Tokyo and Paris (Berlin's time, but Romance Standard Time, another clock): refused as exceptions-would-be-lost: zone; Vienna (the same id) writes the zone alone; a switch that also moves the series writes zone and slot;
    • the same clock under another name: Vienna writes the zone only; a stored Europe/Berlin (through StoredZones::of) with a rule change writes the zone and the rule; no slot in either;
    • a title-only save near midnight, stored in W. Europe or UTC, with and without proven kept fields: the title alone; the same from a copy stale only in its exceptions, also where the stored end zone differs (refused before the sixth check);
    • a slot that only follows the zone is the server's (another device's longer end stays);
    • a different stored end zone moves the clock, and with a deleted occurrence a COUNT change or an UNTIL cut is refused;
    • a kept all-day flag gets no zone, and its rule starts on the all-day series' day; the mirror, a kept timed flag under a stale all-day copy, gets the zone and the server's timed slot after it;
    • a blind update: zone first, title and slot after it, rule last;
    • an edited rule that does not build fails the save, also where the server's did not build either.
    • Adjusted: each_field_is_written_exactly_when_it_changed (a new "zone only" case) and a_rule_change_carries_the_zone_even_when_the_time_stands_still now run on W. Europe storage; the subset invariant allows the server's slot that follows the zone, and the server's rule an all-day series given a time takes along; the 234 test became step 9's.
  • Through the adapter: a W. Europe series read back as Aperio reads it, switched to New York: the recorded UpdateItem names the zone before the slot and no rule. The same series with a deleted occurrence: Forbidden, exceptions-would-be-lost: zone, and no UpdateItem sent.
  • WriteRefusal: the new refusal round-trips; the list of refusals read back now holds all nine.
  • Surfaces (src/state/eventWriteError.test.ts): the refusal, from the desktop and from behind the phone's wrapper, reads as its German sentence and as nothing written; its reason fits the split's sentences.
  • Red proofs: seventeen rules broken one at a time, each fails at least one test: the zone last; the slot not forced; the rule on the old clock; a rule on a zone-only change; the end zone ignored; the forced slot from the edit; an unbuildable rule passing; the zone from the edit's all-day flag; the rule from the edit's slot; the clock read from the tzid instead of the written id; the adapter test with the zone last; the refusal also on a moving slot; no refusal; no slot ahead of the zone when leaving all-day; no forced rule then; the refusal not mapped to Forbidden; a copy's exceptions counted as a rule change (touches(Recurrence) back in place of rule_changed, which fails a_copy_stale_only_in_its_exceptions_writes_the_title_alone).
  • Gates: fmt, workspace clippy -D warnings, cargo test --workspace, cargo check -p adapter-ews, cargo xtask ts-types (bindings copied), vitest, lint, tsc, mobile tsc and check:bindings, both live-test generators, docs build and check:links.

First check (1b832b2)

Two findings were confirmed:

  • The subset invariant had a third, unnamed exception. Where the edit keeps the server's slot, the edit's rule starts on that kept slot's first day, which a write without the copy cannot know. The test passed only because the synthetic all-day start (08:00Z) falls on the same day on every clock it ran on; on a device at UTC-10 it would have failed. The exception is now named in the _on doc and exempted in the test against the rule built on the server's slot, on the same device clock as the code, so the test no longer depends on the machine.
  • "A zone change alone writes no rule, which spares the exceptions" overstated it. A switch that moves the first day (near midnight) rewrites the rule. The live test has since measured what Exchange does with the exceptions (below).

Second check (9f8b7f4)

Six findings were confirmed, four distinct:

  • A fourth exception to the subset invariant. Where the edit keeps the all-day flag of a server item that is timed, the zone is the one a timed item takes, read from the kept slot, which a write without the copy never names. The _on doc now says the zone and the rule written over a kept slot are read from it; the subset matrix gained an all-day series with a zoned rule and a longer series, with the exemption; a dedicated test pins the mirror direction.
  • Comments still said a zone change spares the exceptions (the rule comparison, a test doc). They now say what the docs say.
  • This body described main's zone-picker request wrongly: main also wrote the rule, on every zone switch. Fixed above, and the "same clock" test line.
  • DESIGN "Ungeprüft": since this PR the zone always comes first, so whether Exchange applies a rule before a later zone no longer matters; what stays unmeasured is 234 itself, where the clock stays.

Third check (fixed in 1ec656e)

Two wording findings: DESIGN's "Feldreihenfolge" risk still described the order before this PR, and the clock_moves comment said the edit's slot follows the zone where it is the server's (landed). Both fixed.

Fourth check (fixed in 227b806)

Eight findings were confirmed, all about what the docs and this body say; the code stands:

  • "A time zone with the same time of day, such as Vienna, Aperio saves" promised too much. Clocks are compared by the zone Exchange stores: Vienna is W. Europe like Berlin, but Paris, Brussels, Madrid, Prague or Warsaw are other Windows zones and count as another clock, so on a series with exceptions they are refused too. A test now pins Paris as refused; DESIGN 245, ews.md and a new 🚩 for the zone picker (stages 11, 12) say so: measure first whether another Windows zone alone keeps the exceptions, then compare clocks by their offsets.
  • No editor picks a series' zone yet (stages 11, 12 are unbuilt), so the troubleshooting section described a choice users cannot make. Rewritten in both languages: "Exchange would drop a series' changed and deleted occurrences" — when the message comes today (a repeat change where Aperio would have to write another zone than the stored one: a series whose end zone is not its start zone, or a stale copy), what to do (change the repeat in Outlook; title, place and reminder still save), and that moving such a series still loses the occurrences without a warning until 243. The first section no longer says users switched a stored series' zone.
  • The docs said a zone switch that would drop exceptions is always refused. Only one that writes Start and End because of the zone alone is; DESIGN's Exchange paragraph says so now.
  • DESIGN's open questions still listed two things the live test measured (zone first on timed series; exceptions after Start/End), and said the zone "always" comes first. They now name what is still open: another Windows zone alone, and rule rewrites beyond COUNT.
  • Run names: ews.md cited L4a-e for the cross-clock switch, which L3a and L3b measured; DESIGN 243 credited M6 to a move. Fixed.
  • This body named 9f8b7f4 for the third check's fixes; they are in 1ec656e.

Ten findings were refuted, among them: a rule change refused on a series whose stored end zone differs from its start zone (the documented 245 behaviour, and a shape Aperio never creates); a blind all-day write still sending the zone first (unchanged blind path); the refusal's wording; N3 writing the same id (true, and now said so).

Fifth check (fixed in 02dad28)

Five findings were confirmed, all about what the docs and this body say:

  • The guides named only a repeat change, but a series changed or deleted from one of its appointments on rewrites the old series' repeat too (the cut), and meets the same refusal on the same shapes. Both languages say so now, and the advice covers it.
  • "The zone first is refused" was measured on a daily series only (L2); a weekly one took it (round 2, B4). ews.md, the guides, DESIGN 244 and this body say "daily", and that every all-day series gets the measured order.
  • DESIGN and the rule comment named only COUNT as measured; the UNTIL cut of the 8a live test kept a deleted occurrence.
  • This body named 1ec656e for the fourth check's fixes (they are in 227b806), and still spoke of "Vienna chosen in the picker".

Two tests the reviewers missed were added although their findings were refuted as defects: O1's request, and the end-zone refusal on a COUNT change and an UNTIL cut. Also refuted: the desktop group-carry dialog showing a refusal token in English (older than this PR, for every refusal; flagged as its own task); a German sentence's style.

Sixth check (fixed in 8d5c1a5)

One finding was confirmed, and it was a code gap: the zone gate asked whether the edit touches Recurrence, and the core counts a series' exceptions as part of it. A copy stale only in its exceptions (another occurrence deleted since it was read, nothing proven kept) therefore opened the zone gate, and where the stored end zone differs from the start, 245 refused a plain rename, although the guide says a title still saves. The writer now asks whether the rule's text or zone changes (rule_changed), in the zone gate, the slot a changed rule brings along and the blind/unbuildable rule terms. Such a save writes the title alone, the request L4a measured. Test and red proof. The guides add: if the series was changed elsewhere, open it again once Aperio has refreshed, and try again.

Refuted: a test gap in leaves_all_day (the code is right); deleting "this and all following" from a view showing the token raw (older than this PR: the views announce every error raw, already in TODO since 118); the guide not saying that a longer or all-day change also drops occurrences (243's scope); B4 cited for the weekly case; the ExceptionsWouldBeLost doc wording.

Seventh check (8d5c1a5)

No finding in the code. One in this body: the red-proof list still said sixteen and left out the sixth check's proof; it now lists seventeen. Refuted: the subset test's exemption for the rule an all-day series given a time takes along hides no wrong block (every matrix cell it fires in was traced).

Live test (decision 242)

A local test program drives Aperio's adapter against Toni's Exchange 2019 and reports the fields of each UpdateItem in order, and what the calendar view shows after it.

Round 1, Aperio's request:

  • L1, L1b — a single made weekly, in Berlin across the EU change (Mon 19.10. 00:30) and in Tokyo: right.
  • L2 — an all-day series from Sat 24.10. given 10:00-11:00: refused whole, ErrorOccurrenceTimeSpanTooBig. L2b, the flag first (not Aperio's request): refused too.
  • L3a, L3b — a W. Europe series with a deleted and a moved occurrence, switched to New York (same day; Sunday): the instants are right, the exceptions are gone.
  • L4a-e — title only, an hour later, the rule only, Vienna, a day later on Tuesdays: all right.

Round 2, requests built by hand:

  • M1, M2 — all-day given a time: flag, slot, zone, slot, in one request or in two: accepted, but the series starts a day late (Sun 25.10.).
  • M3 — a series with exceptions moved two hours, no zone: exceptions lost. M4 — title only: kept. M5 — COUNT 4 → 5: kept. M6 — the zone switch with the rule sent again: lost.

Round 3, by hand:

  • M7, M8 — M1 and M2 with the rule last, StartDate 24.10.: right, in one request and in two. This is 244's shape.

Round 4, Aperio's own request at 1ec656e:

  • N1, N1b — an all-day series from Sat 24.10. given 10:00 and 00:30: Aperio sent IsAllDayEvent, Start, End, StartTimeZone, EndTimeZone, Start, End, Recurrence; both right, from Saturday on, across the EU change.
  • N2 — the New York switch on a series with a deleted and a moved occurrence: refused (exceptions-would-be-lost: zone), nothing sent, the series and its exceptions unchanged.
  • N3 — Vienna instead of Berlin on the same series: the zone alone went out (the same id, W. Europe), the exceptions kept.

Round 5, Aperio's own request at 227b806: an all-day series as Outlook stores it, already in W. Europe, given 10:00. The zone does not change there, yet the series leaves all-day (244); M7 and N1 measured it from UTC only.

  • O1 — daily from Sat 24.10.: Aperio sent IsAllDayEvent, Start, End, StartTimeZone, EndTimeZone, Start, End, Recurrence; right, 10:00 Berlin across the EU change.
  • O2 — weekly on Saturdays from 24.10.: the same request; right (24.10., 31.10., 7.11.).

Docs

  • ews.md: "An update writes the zone first", "An all-day series given a time", "Exchange drops a series' exceptions", and the first-day rule.
  • DESIGN-series-time-zone.md: decisions 240-245; the Exchange paragraph; round 1's B2 note; the mislabelled "Stufen 9 und 12" for round 2's B4; "Ungeprüft"; the "Feldreihenfolge" risk.
  • TODO: the live results, 243 and the zone picker's same-time question as their own 🚩.
  • Troubleshooting guide, German and English: "An Exchange appointment moved after it became a series" (now also the all-day order), and the new "Exchange would drop a series' changed and deleted occurrences", with when it comes and what to do.
  • Comments: the update builders, rule_first_day, read_before, REFUSED_UPDATE_CODES, the round 1/2 generator (historical since this change).

The phone needs a fresh .so and XCFramework.

🤖 Generated with Claude Code

Timtam and others added 4 commits October 10, 2026 20:34
…es, the rule last

A new StartTimeZone keeps an item's stored wall clock and relabels it in the
new zone (live round 1, B2; the 8a live test, step 9). The update builder
wrote the zone last, so every update that changed an Exchange item's clock
moved it by the offset: a single created in Aperio made a series (Monday
00:30 in Berlin landed on Sundays at 23:30), an all-day series given a time
of day, and the zone picker on a stored series, which keeps the instant.

- The zone goes first whenever it is written (the series' zone, its all-day
  flag or its slot changes); the rule no longer opens that gate.
- Where the written zone names another clock than the stored one, on either
  boundary and compared as the read side maps an id, Start, End and
  IsAllDayEvent follow it even where they did not change (decision 240),
  with the server's values where the edit kept them (106).
- The rule comes last, built on the new zone's clock where the clock moves,
  and is written only when its built form differs from the server's
  (decision 241): a zone change alone writes no rule and spares the
  series' exceptions. A blind write still writes or deletes the rule, and
  an edited rule that no longer builds still fails the save.
- The zone and the rule's first day are read from the slot the server keeps
  after the update, so a kept all-day flag never gets a zone (46a) and a
  rule never starts on this device's stale start.

Tests for every case, eleven red proofs, an adapter-level test of the
recorded request; docs (ews.md, DESIGN, TODO, the troubleshooting guides)
and the comments that described the old order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a zone change rewrites

- Where the edit keeps the server's slot, the edit's rule starts on that
  kept slot's first day, which a write without the copy cannot know: a
  third exception to the subset invariant. The test passed only because
  its synthetic all-day start fell on the same day on every clock it ran
  on. The exception is named in the `_on` doc and exempted in the test
  against the rule built on the server's slot, on the same device clock as
  the code.
- A zone change writes no rule only while the series' first day and
  weekday stay the same on the new clock; near midnight it rewrites the
  rule. Whether Exchange keeps the exceptions when the slot or the rule is
  written again is measured by the live test, not claimed (ews.md, DESIGN).

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

- Where the edit keeps the all-day flag of a server item that is timed, the
  zone is the one a timed item takes, read from the kept slot like the rule:
  a fourth exception to the subset invariant, now named in the `_on` doc.
  The subset matrix gained an all-day series with a zoned rule and a longer
  series, with the exemption, and a dedicated test pins the mirror of the
  kept all-day flag: a stale all-day copy over a timed server series gets
  the zone and the server's timed slot after it. Red proof: the zone read
  from either flag fails it.
- The rule comparison's comment and a test doc no longer say a zone change
  spares the series' exceptions.
- DESIGN "Ungeprüft": with the zone always first, whether Exchange applies
  a rule before a later zone no longer matters; 234 itself stays inferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o zone switch that drops exceptions

The zone-first live test measured what #122's first order could not know:

- An all-day daily series given a time refuses the zone first whole
  (ErrorOccurrenceTimeSpanTooBig, L2). Flag and slot, zone, slot again is
  accepted but lands a day late (M1, M2); with the rule last, on the new
  zone's day, it lands right (M7, M8). The writer now sends exactly that
  shape when the server's item is all-day and the edit is timed, the rule
  always (decision 244). ErrorOccurrenceTimeSpanTooBig is a whole-request
  refusal from now on.
- Exchange drops every changed and deleted occurrence when a master's Start
  and End are written (L3a, L3b, M3, M6); a title or a COUNT keeps them (M4,
  M5). Where Start and End are written only because the zone moves the clock,
  on a series that has any, nothing is sent: a new WriteRefusal,
  exceptions-would-be-lost, carried as Forbidden, with its own sentence in
  German and English on both surfaces (decision 245). A save that moves the
  series writes them as before; asking first is decision 243, the next PR.

Tests for both, an adapter test that nothing is sent, five red proofs; the
subset invariant names the server's rule an all-day series takes along.
Docs: ews.md, DESIGN (243-245 and the live results), TODO, the
troubleshooting guides (a new section for the refusal). Also the third
check's two wording fixes (the Feldreihenfolge risk, the clock_moves
comment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam Timtam changed the title Exchange: write the zone first, the slot after it, the rule last (240-242) Exchange: zone first, the measured all-day order, no zone switch that drops exceptions Oct 10, 2026
Timtam and others added 3 commits October 10, 2026 23:14
…clock, and the live results

- No editor picks a series' zone yet (stages 11 and 12), so the new
  troubleshooting section described a choice users cannot make. Rewritten in
  both languages as "Exchange would drop a series' changed and deleted
  occurrences": when the message comes today (a repeat change where Aperio
  would have to write another zone than the stored one), what to do, and that
  moving such a series still loses the occurrences until 243 asks first.
- Clocks are compared by the zone Exchange stores, so Paris (Romance Standard
  Time) is another clock than Berlin though it shows the same time: refused
  on a series with exceptions. A test pins it; the docs no longer promise
  that a zone with the same time of day saves; a TODO flag for the picker.
- DESIGN: the open questions the live test answered are gone, the ones still
  open named; the Exchange paragraph says only a zone-only rewrite is
  refused; M6 and L3a/L3b credited to the right runs; round 4 recorded
  (N1, N1b, N2, N3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… for L2, and round 5

- The troubleshooting guides named only a repeat change, but Aperio writes
  the repeat again when a series is changed or deleted from one of its
  appointments on, too (it shortens the old series). Both languages now say
  so, and the advice covers it: do those in Outlook; title, place and
  reminder of the whole series or of one appointment still save.
- L2 measured the zone first refused on a DAILY all-day series; a weekly one
  took it (round 2, B4). ews.md, the guides and DESIGN 244 say "daily", and
  that every all-day series gets the measured order all the same.
- DESIGN and the rule comment named only COUNT as measured; the UNTIL cut of
  the 8a live test kept a deleted occurrence. What stays unmeasured is said.
- Round 5 recorded: an all-day series Outlook stored in W. Europe, given
  10:00, lands right, daily and on Saturdays (O1, O2).
- Two tests: O1's request, and a COUNT change or UNTIL cut refused on a
  series whose stored end zone is not its start zone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The zone gate, the slot a changed rule brings along, and the blind or
unbuildable rule write all asked whether the edit touches Recurrence, and
the core counts a series' exceptions as part of it. An update never writes
exceptions (a deleted or changed occurrence is an item of its own), so a
copy that differs from the server only in them — a stale one, once another
occurrence was deleted, with nothing proven kept — opened the zone gate: on
W. Europe storage a rename wrote the zone too, and where the stored end
zone is not the start's, decision 245 refused the rename although the guide
says a title still saves. The writer now asks whether the rule's text or
zone changes (rule_changed). Such a save writes the title alone, the shape
L4a measured. Test and red proof.

The guides now also say: if the series was changed elsewhere, open it again
once Aperio has refreshed the calendar, and try again. DESIGN 245 and
ews.md name the rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam
Timtam merged commit 4142cc4 into main Oct 10, 2026
13 checks passed
@Timtam
Timtam deleted the fix/ews-zone-before-slot branch October 10, 2026 22:40
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