Skip to content

Say a refused update is a refusal, in every adapter - #98

Merged
Timtam merged 3 commits into
mainfrom
fix/refusals-marked
Sep 26, 2026
Merged

Timtam merged 3 commits into
mainfrom
fix/refusals-marked

Conversation

@Timtam

@Timtam Timtam commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Third PR of the arc "exceptions when splitting a series" (decision 134), decision 147. It follows #97.

Why

Since #97, a split creates the new series first and then truncates the old one. When the truncate fails, the new series is deleted again only if the truncate certainly wrote nothing: writeNeverLanded checks for a refusal token or a refusing code. Several certain refusals did not arrive that way:

  • EWS SOAP error answers to the one UpdateItem, such as ErrorServerBusy, arrived as protocol;
  • Google and Graph answers with HTTP 400 or 429 arrived as protocol;
  • CalDAV's own checks before the PUT ("nothing was saved") arrived as protocol.

In each case both series stayed, with the warning that the series may show twice, although it certainly did: nothing had been cut. And Graph's DELETE after a successful /cancel may find nothing, because the cancel moves the event to Deleted Items and a move gives an Outlook item a new id. So undoing the new series of a meeting reported a failure, and the series "may show twice", while it had been cancelled and removed.

The change

One rule in cal-core. WriteRefusal::refused_status(status) covers the statuses with which a server turned a write down whole: 400, 413, 415, 422, 429, 507. The statuses the adapters already name (401, 403, 404, 409, 412) keep their errors. A 5xx says nothing either way and stays unsure.

Every adapter's update_event reports such a refusal as one. Each adapter maps it to Forbidden("server-refused: …"). The forbidden code reaches JS on the phone too, where most other codes do not (TODO B9).

  • Google, Graph: to_update_error applies the status rule.
  • EWS: the status rule, and every SOAP error answer to the one UpdateItem, except the server's internal and timeout errors, which may come after part of the work. Sign-in and not-found faults keep their errors.
  • CalDAV: the status rule. Its own checks before the PUT now say unsafe-to-write, a new WriteRefusal meaning "Aperio cannot change this event safely". The detail (unreadable-blocks, no-such-event, mixed-organizers) is a token for the log; the prose goes to a warn!.

Graph: a DELETE that finds nothing after a successful /cancel is done. Without a cancel, a 404 is still "not found". The cancel_event doc said the event stays on the calendar until deleted; corrected.

Frontend:

  • unsafe-to-write has its sentence and its reason in EN and DE (shared/eventWriteError.ts).
  • The generated WriteRefusal type now lists it, so both apps' tables must name it.

In passing: WriteRefusal::parse did not know occurrence-not-writable. It is unused outside tests, and now knows every refusal.

Not here: creating and deleting still report such refusals as protocol. Only the truncate decides anything in a split, and the messages elsewhere are unchanged.

Docs: DESIGN.md lists the tokens and what a token means for writeNeverLanded. In TODO.md the 147 entry is done, and a sentence my earlier edit had put in the wrong entry is back in place.

Checks

  • Rust:
    • cargo test --workspace --all-features: 2861 passed.
    • fmt and clippy -D warnings clean.
    • cal-core and each of the four adapters checked alone: clean.
    • cargo xtask ts-types --check current (WriteRefusal.ts).
  • TypeScript:
    • desktop and mobile tsc and eslint clean;
    • vitest 2192 passed, locally and under TZ=UTC;
    • check:bindings passes.
  • New tests:
    • cal-core: every refusal is read back, and its token is its serialized name; the refusing statuses, and the ones that are not.
    • Google: an update answered 400 or 429 is server-refused: HTTP …; one answered 503 stays a protocol error. Both run through the adapter against a mock server.
    • Graph:
      • an update answered 400 is a refusal, through the adapter;
      • a DELETE answered 404 after /cancel is done;
      • a DELETE answered 404 without a cancel is not found;
      • a DELETE answered 500 after a cancel is still an error.
    • EWS: SOAP answers to an update are refusals; internal-server and timeout codes stay unsure; sign-in and not-found keep their errors; HTTP 429 is a refusal and 503 is not.
    • CalDAV:
      • a resource whose blocks name different organizers is refused as unsafe-to-write: mixed-organizers, with no PUT sent;
      • the update mapping: the refusing statuses; 500 unsure; 412 a conflict.
    • TS: unsafe-to-write reads as its sentence, and as nothing written; server-refused: HTTP 429 counts as nothing written.
  • Red proofs, 10 of 10 red, tree restored byte for byte, each run time-limited:
    • a 429 not counted as a refusal;
    • Google's update mapped as before;
    • Graph's update mapped as before;
    • a 404 after the cancel read as a failure;
    • a 404 without a cancel read as done;
    • an EWS internal server error counted as a refusal;
    • an EWS SOAP answer not read as a refusal;
    • CalDAV's own refusal read as a protocol error;
    • CalDAV's server refusal read as a protocol error;
    • an occurrence refusal not read back.
  • Not covered by a test: the EWS and CalDAV update_event wiring. Their mapping functions are tested, but the one map_err in each adapter's trait method has no harness for a full update.

Review round

One adversarial round, with two lenses: could a write that landed now read as a refusal, and what does the new error touch elsewhere. Every finding was verified: 9 confirmed, several of them found by both lenses, and 4 refuted. Fixed here:

  • EWS, medium: "every SOAP error except internal and timeout wrote nothing" was a denylist, and too broad. A meeting update with notifications saves the item first and then sends it. Codes raised while sending (send-as denied, submission or mailbox quota, message size, the store going away), the synthetic Unknown, and generic faults can all come after the save, and reading them as refusals deleted the new series around a truncate that had landed. REFUSED_UPDATE_CODES is now an allowlist of the codes Exchange raises while it checks or throttles the request: ErrorServerBusy, the invalid-request and schema codes, the invalid-property codes, an invalid recurrence, and a malformed id or change key. Every other code stays unsure.
  • EWS, low: ErrorServerBusy usually comes as a SOAP fault with HTTP 500, which the client returns as an HTTP error before it reads the fault. The update's mapping now reads the fault from a 500 answer and strips the namespace prefix (a:ErrorServerBusy). Any other 500 stays unsure.
  • CalDAV, medium: a replayed PUT answered 429, 507 or another refusing status read as a refusal, but the first attempt may have landed. The replay guard from Split a series by creating the new part first #97 caught only 412. Any failure on a replay whose first attempt may have landed is now a network error ("it may have been saved").
  • Google and Graph, low: when the token refresh during a save failed, the token endpoint's 400 (invalid_grant) read as "the calendar server refused this change (HTTP 400)". refresh_access_token now reports a 400 or 401 from the token endpoint as a 401, so it is the sign-in failure it is. That also matches what the Google module doc already claimed. A refused update also carries the server's own reason ({"error":{"message":…}}), trimmed, after the status.
  • CalDAV, low: to_core_error's doc comment had ended up on the new function; moved back.
  • TODO.md: the 147 entry describes the allowlist, the 500 faults, the replay rule and the token refresh.
  • Refuted:
    • update_override's pre-PUT refusals are still protocol errors, but they are single-occurrence writes, and a split never consults them;
    • the unsafe-to-write sentence;
    • the 429 wording;
    • the docs' scope.

Checks for the round:

  • Rust: 2865 passed; fmt and clippy -D warnings clean; each of the four adapters checked alone: clean.
  • New tests:
    • EWS: send-time, store, Unknown and generic codes stay unsure; a a:ErrorServerBusy fault sent with HTTP 500 is a refusal, while a 500 internal-server-error fault and a non-SOAP 500 page stay unsure.
    • CalDAV: a 429 on a replayed PUT is unsure.
    • Google and Graph: a refused token refresh during an update is a sign-in failure. Both run through the adapter against a mock token endpoint.
    • Google: the reason travels with the status.
  • Red proofs, 7 of 7 red, tree restored byte for byte:
    • only a 412 on a replay treated as unsure;
    • every EWS error code read as a refusal;
    • a 500 fault not read;
    • the fault's namespace prefix kept;
    • Google's refused refresh read as its status;
    • Graph's refused refresh read as its status;
    • Google's reason left out.

Second review (of the review round)

Two lenses (EWS and CalDAV; OAuth): 5 confirmed, all low; 5 refuted. All fixed here:

  • EWS: ErrorIrresolvableConflict and ErrorStaleObject are off the refusal list. The update is sent with AlwaysOverwrite, so no ChangeKey is checked before the save, and a conflict code comes from somewhere else. It stays unsure.
  • CalDAV: a replayed PUT answered with a failure keeps its answer. The status goes into the error, and the status and body into the log; the replay rule had dropped both. Narrowing the rule to answers that can only be the first attempt's (409, 412, 429, 507, 5xx) was considered and left out. Replays are rare since the 25-second pool timeout, and every wrong call it makes is in the safe direction: both series stay, with the warning.
  • Google and Graph: the error keeps only the first 300 characters of an answer, so a realistic envelope arrived as broken JSON and its reason was lost. server_reason now reads the first "message" string of a cut answer as far as it goes.
  • host-core: is_auth_shaped's doc and test name the shape Google and Graph now report for a refused refresh, beside the one other OAuth adapters still send.
  • TODO.md names the two tokens that travel as forbidden, and says where the replay and refresh failures travel.

Checks for the round:

  • Rust: 2867 passed; fmt and clippy -D warnings clean.
  • New tests: the conflict codes stay unsure; a reason is read from a cut Google or Graph envelope; the replay's status is in the CalDAV error; the current refresh shape is auth-shaped.
  • Red proofs, 4 of 4 red: a conflict code read as a refusal; the cut answer not read (Google, and Graph); the replay's answer dropped.

Phone

A new .so is needed for the adapters' new errors. With an older one the phone behaves as before #97's refusal check: both series stay, with the warning.

🤖 Generated with Claude Code

Timtam and others added 3 commits September 26, 2026 13:14
Since #97 a split creates its new series first and takes it back only when
the truncate certainly wrote nothing (`writeNeverLanded`: a refusal token
or a refusing code). Several certain refusals arrived as `protocol`: EWS
SOAP error answers to the one `UpdateItem`, Google and Graph HTTP 400 and
429, and CalDAV's own checks before the PUT. Both series then stayed with
the warning that the series may show twice, although it certainly did
(decision 147).

- cal-core: `WriteRefusal::refused_status` names the statuses with which a
  server turned a write down whole (400, 413, 415, 422, 429, 507), and the
  new `UnsafeToWrite` refusal says Aperio did not write because it could
  not do so safely. `parse` now knows every refusal
  (`occurrence-not-writable` was missing).
- Each adapter's `update_event` maps such an answer to
  `Forbidden("server-refused: …")`; EWS also every SOAP error answer to the
  update except internal and timeout errors; CalDAV's own checks say
  `unsafe-to-write` with a log token. `forbidden` reaches JS on the phone.
- Graph: a DELETE that finds nothing after a successful `/cancel` is done:
  the cancel moved the event to Deleted Items, which gives it a new id.
- Frontend: `unsafe-to-write` has its sentence and reason in EN and DE.
- DESIGN.md and TODO.md follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- EWS: the denylist ("every SOAP error but internal and timeout") took in
  codes a meeting update raises after it saved the item and while it sends
  it (send-as denied, quotas, message size, the store), `Unknown` and
  generic faults. `REFUSED_UPDATE_CODES` is now an allowlist of the codes
  Exchange raises while it checks or throttles the request; any other code
  stays unsure. The code is also read from a SOAP fault sent with HTTP 500,
  the usual shape of `ErrorServerBusy`, without its namespace prefix.
- CalDAV: any failure of a replayed PUT whose first attempt may have
  landed is unsure, not only a 412: a 429 or 507 may be about the first
  attempt.
- Google and Graph: a token endpoint that refuses the refresh (400/401,
  `invalid_grant`) is a sign-in failure, reported as a 401, not the
  calendar server refusing the change. A refused update carries the
  server's own reason after the status.
- CalDAV: `to_core_error`'s doc comment is back on it.
- TODO.md follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- EWS: `ErrorIrresolvableConflict` and `ErrorStaleObject` are off the
  refusal list. The update is sent with `AlwaysOverwrite`, so no ChangeKey
  is checked before the save, and a conflict code comes from somewhere
  else; it stays unsure.
- CalDAV: a replayed PUT answered with a failure keeps its answer: the
  status goes into the error and status and body into the log, where the
  replay rule had dropped both.
- Google and Graph: the error keeps only the first 300 characters of an
  answer, so a realistic envelope arrived as broken JSON and its reason
  was dropped. `server_reason` now reads the first "message" string of a
  cut answer as far as it goes.
- host-core: `is_auth_shaped`'s doc and test name the shape Google and
  Graph now report for a refused refresh, beside the one other OAuth
  adapters still send.
- TODO.md names the two tokens that travel as `forbidden`, and where the
  replay and refresh failures travel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Timtam
Timtam merged commit 7a971f5 into main Sep 26, 2026
11 checks passed
@Timtam
Timtam deleted the fix/refusals-marked branch September 26, 2026 13:25
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