feat(mcp): add delete_occurrence, refusing when it would send calendar mail #19

Merged
jlxq0 merged 5 commits from feat-delete-occurrence into main 2026-09-02 03:48:27 +00:00
Owner

Blocked on #16's measurement. Labelled blocked and waiting-on-julian.

What unblocks it: a Stalwart-local credential on the caldav_test
principal, dispatched by Clark, so the pre-registered experiment on #16 can
run. The account exists; it has no credential set. Both fixtures were checked
against the live outbound routing table rather than against their names, and
neither appears in it with aliases=0 and groups=0, so a credential on them
cannot affect anyone's mail.

Correcting what this said an hour ago. I wrote that the blocker was
Stalwart's directory being OIDC and therefore unable to hold passwords. That
is wrong: 6 of 26 principals there carry a credential, and non-human probe
accounts with passwords already exist on it. The 401 I kept re-measuring
cannot distinguish "the directory cannot hold passwords" from "this principal
has none", so no number of re-runs could have told me. It was an inference I
received marked as one and repeated unmarked.

Why the credential and not a rebase: the branch is current with main and
mergeable, and CI is green. Nothing about the code is waiting.

What the measurement decides: whether an EXDATE this tool writes is
honoured or silently ignored, which is the entire hazard. Two of the refusals
in this change are pre-registered to be removed if it turns out they were
superstition, so merging first would ship guards nobody has tested.

Refs #16, step 3. The delete half of "I need to be able to delete or potentially change individual elements of a series."

Hold this merge

Do not merge until the EXDATE behaviour is measured against a real Stalwart. Every test below runs against a mock, so they prove this repository writes the iCalendar it intends to write. None of them proves the server acts on it. That is exactly the claim #16 calls the whole hazard — a PUT the server accepts while the occurrence keeps rendering — and it is unmeasured until caldav-test@kampong.social exists.

Merging this green would be reporting a check as passed that was never run against the thing it is about.

What it does

Adds an EXDATE to the master and deletes the override for that occurrence, in one conditional PUT.

The value type and TZID come from the master's DTSTART, never from the caller. RFC 5545 requires the exclusion to match the occurrence as the RRULE generates it, and a mismatch is accepted and excludes nothing. Taking both from DTSTART makes the wrong shape unrepresentable rather than merely documented — the caller supplies only the value.

The override goes in the same write. Left behind it is an orphan: clients render an occurrence the series says does not exist.

Three refusals, each because the server would accept and then not act

  • a recurrence_id whose value type disagrees with DTSTART
  • the series' first occurrence, since some servers treat DTSTART as implicitly included
  • an occurrence whose override carries RANGE=THISANDFUTURE, which applies to that occurrence and every later one and is a different operation

That third one is the explicit rejection #16 asked for on the write side. The read side landed in 3dfc511.

The scheduling refusal

Refuses when the object carries any ATTENDEE, naming every one, unless the call passes send_scheduling_messages: true.

There is no way to make this write quiet, and that is a property of the server rather than a gap here: Stalwart parses Schedule-Reply: F but consults it only in crates/dav/src/calendar/delete.rs; update.rs never reads it. SCHEDULE-AGENT=CLIENT does work on a PUT and is persistent data that would also silence every later legitimate update. So the only honest options are sending the mail and not touching the event, and those are the only two offered. Per call rather than per session, because the cost is paid per operation.

It names every attendee, including those on overrides, and says the server decides which are actually mailed — send_update_messages() requires !email.is_local, and we cannot tell which domains Stalwart hosts. Naming too many is recoverable; naming too few puts mail in a stranger's inbox.

The addresses stay out of telemetry: audit::error_class records a static class string, never the message.

What it does not do

recurrence_id is trusted as given. There is no RRULE expander here, so an unmatched value writes an EXDATE that excludes nothing. The tool description says that in those words rather than implying a validation that does not happen — an honest limitation beats a complete one later, and a caller looking at an occurrence in a client already has the value.

Verified by mutation

Five, each with the pattern asserted to apply and the harness checked for a test result: line, after both halves of that went wrong earlier today.

mutation died
EXDATE head loses TZID and VALUE=DATE the two shape tests and the wiremock PUT body assertion
override left in place excluding_an_occurrence_writes_an_exdate_shaped_like_dtstart, and the wiremock test
idempotent short-circuit removed excluding_twice_is_idempotent and delete_occurrence_does_not_put_when_already_excluded
attendee guard removed an_attended_series_refuses_and_names_every_attendee, alone
RANGE check removed a_this_and_future_override_is_rejected_rather_than_excluded, alone

The third row is the one worth reading: an already-excluded occurrence must produce no PUT at all, not a no-op PUT. A no-op write bumps SEQUENCE and, on an attended series, sends mail for a change that did not happen. delete_occurrence_does_not_put_when_already_excluded is a wiremock test with no PUT mock mounted, so a stray write fails verification rather than passing unnoticed.

Gates

cargo fmt --check, cargo clippy -D warnings, cargo test --all-features (120 + 9), cargo audit, cargo deny check bans licenses sources — green on rustc 1.98.0, through the shared build slot.

> **Blocked on #16's measurement.** Labelled `blocked` and `waiting-on-julian`. > > **What unblocks it:** a Stalwart-local credential on the `caldav_test` > principal, dispatched by Clark, so the pre-registered experiment on #16 can > run. The account exists; it has no credential set. Both fixtures were checked > against the live outbound routing table rather than against their names, and > neither appears in it with `aliases=0` and `groups=0`, so a credential on them > cannot affect anyone's mail. > > **Correcting what this said an hour ago.** I wrote that the blocker was > Stalwart's directory being OIDC and therefore unable to hold passwords. That > is wrong: 6 of 26 principals there carry a credential, and non-human probe > accounts with passwords already exist on it. The 401 I kept re-measuring > cannot distinguish "the directory cannot hold passwords" from "this principal > has none", so no number of re-runs could have told me. It was an inference I > received marked as one and repeated unmarked. > > **Why the credential and not a rebase:** the branch is current with `main` and > `mergeable`, and CI is green. Nothing about the code is waiting. > > **What the measurement decides:** whether an `EXDATE` this tool writes is > honoured or silently ignored, which is the entire hazard. Two of the refusals > in this change are pre-registered to be **removed** if it turns out they were > superstition, so merging first would ship guards nobody has tested. Refs #16, step 3. The delete half of *"I need to be able to delete or potentially change individual elements of a series."* ## Hold this merge **Do not merge until the `EXDATE` behaviour is measured against a real Stalwart.** Every test below runs against a mock, so they prove this repository writes the iCalendar it intends to write. None of them proves the server acts on it. That is exactly the claim #16 calls the whole hazard — a `PUT` the server accepts while the occurrence keeps rendering — and it is unmeasured until `caldav-test@kampong.social` exists. Merging this green would be reporting a check as passed that was never run against the thing it is about. ## What it does Adds an `EXDATE` to the master and deletes the override for that occurrence, in one conditional `PUT`. **The value type and `TZID` come from the master's `DTSTART`, never from the caller.** RFC 5545 requires the exclusion to match the occurrence as the `RRULE` generates it, and a mismatch is accepted and excludes nothing. Taking both from `DTSTART` makes the wrong shape unrepresentable rather than merely documented — the caller supplies only the value. **The override goes in the same write.** Left behind it is an orphan: clients render an occurrence the series says does not exist. ## Three refusals, each because the server would accept and then not act - a `recurrence_id` whose value type disagrees with `DTSTART` - the series' first occurrence, since some servers treat `DTSTART` as implicitly included - an occurrence whose override carries `RANGE=THISANDFUTURE`, which applies to that occurrence **and every later one** and is a different operation That third one is the explicit rejection #16 asked for on the write side. The read side landed in `3dfc511`. ## The scheduling refusal Refuses when the object carries any `ATTENDEE`, naming every one, unless the call passes `send_scheduling_messages: true`. There is no way to make this write quiet, and that is a property of the server rather than a gap here: Stalwart parses `Schedule-Reply: F` but consults it only in `crates/dav/src/calendar/delete.rs`; `update.rs` never reads it. `SCHEDULE-AGENT=CLIENT` does work on a `PUT` and is persistent data that would also silence every later legitimate update. So the only honest options are sending the mail and not touching the event, and those are the only two offered. Per call rather than per session, because the cost is paid per operation. It names **every** attendee, including those on overrides, and says the server decides which are actually mailed — `send_update_messages()` requires `!email.is_local`, and we cannot tell which domains Stalwart hosts. Naming too many is recoverable; naming too few puts mail in a stranger's inbox. The addresses stay out of telemetry: `audit::error_class` records a static class string, never the message. ## What it does not do `recurrence_id` is trusted as given. There is no `RRULE` expander here, so an unmatched value writes an `EXDATE` that excludes nothing. The tool description says that in those words rather than implying a validation that does not happen — an honest limitation beats a complete one later, and a caller looking at an occurrence in a client already has the value. ## Verified by mutation Five, each with the pattern asserted to apply and the harness checked for a `test result:` line, after both halves of that went wrong earlier today. | mutation | died | |---|---| | `EXDATE` head loses `TZID` and `VALUE=DATE` | the two shape tests and the wiremock `PUT` body assertion | | override left in place | `excluding_an_occurrence_writes_an_exdate_shaped_like_dtstart`, and the wiremock test | | idempotent short-circuit removed | `excluding_twice_is_idempotent` and `delete_occurrence_does_not_put_when_already_excluded` | | attendee guard removed | `an_attended_series_refuses_and_names_every_attendee`, alone | | `RANGE` check removed | `a_this_and_future_override_is_rejected_rather_than_excluded`, alone | The third row is the one worth reading: an already-excluded occurrence must produce **no `PUT` at all**, not a no-op `PUT`. A no-op write bumps `SEQUENCE` and, on an attended series, sends mail for a change that did not happen. `delete_occurrence_does_not_put_when_already_excluded` is a wiremock test with no `PUT` mock mounted, so a stray write fails verification rather than passing unnoticed. ## Gates `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test --all-features` (120 + 9), `cargo audit`, `cargo deny check bans licenses sources` — green on `rustc 1.98.0`, through the shared build slot.
jlxq0 referenced this pull request from a commit 2026-08-27 02:10:22 +00:00
Author
Owner

Still held. This is maintenance, not a step toward merging.

Brought up to date with main at b96aa45, green on both contexts, mergeable=true again. The hold is unchanged: every test here runs against a mock, none proves Stalwart acts on the write, and "the server accepts the PUT and the occurrence still renders" is the whole hazard. The measurement on #16 is still unrun and still waiting on one token.

Why now rather than at merge time. Nine AGENTS.md entries have landed on main since this branch was cut, and it had gone to mergeable=false on a content conflict. Resolving that at the moment the hold lifts would mean doing it under time pressure, next to a decision about whether a measurement cleared. Doing it now separates the two.

A merge rather than a rebase, deliberately. A rebase would need a force push, which costs a human approval, and habitual force-pushing removes the signal from the ones that are real. Nothing here needs history rewritten: the conflict is purely additive, both sides append to Known Pitfalls, so both sets are kept.

What changed in the branch: nothing of its own. delete_occurrence and its five mutation-verified guards are as they were at 1e88356. The merge brings in the v0.2.0 and v0.2.1 work from main — the limiter observability, get_event_raw, the master-component fix — and 128 tests pass together.

Note for whoever merges this eventually: main is now protected. Direct pushes are refused, CI / cargo* is required, and approvals are zero, so merging this remains a one-call operation once the measurement clears.

## Still held. This is maintenance, not a step toward merging. Brought up to date with `main` at `b96aa45`, green on both contexts, `mergeable=true` again. **The hold is unchanged**: every test here runs against a mock, none proves Stalwart acts on the write, and *"the server accepts the `PUT` and the occurrence still renders"* is the whole hazard. The measurement on #16 is still unrun and still waiting on one token. **Why now rather than at merge time.** Nine `AGENTS.md` entries have landed on `main` since this branch was cut, and it had gone to `mergeable=false` on a content conflict. Resolving that at the moment the hold lifts would mean doing it under time pressure, next to a decision about whether a measurement cleared. Doing it now separates the two. **A merge rather than a rebase, deliberately.** A rebase would need a force push, which costs a human approval, and habitual force-pushing removes the signal from the ones that are real. Nothing here needs history rewritten: the conflict is purely additive, both sides append to Known Pitfalls, so both sets are kept. **What changed in the branch:** nothing of its own. `delete_occurrence` and its five mutation-verified guards are as they were at `1e88356`. The merge brings in the v0.2.0 and v0.2.1 work from `main` — the limiter observability, `get_event_raw`, the master-component fix — and 128 tests pass together. **Note for whoever merges this eventually:** `main` is now protected. Direct pushes are refused, `CI / cargo*` is required, and approvals are zero, so merging this remains a one-call operation once the measurement clears.
jlxq0 force-pushed feat-delete-occurrence from d3f6bd2b25
All checks were successful
CI / cargo (pull_request) Successful in 46s
CI / docker (pull_request) Successful in 57s
to c67fab7c6e
All checks were successful
CI / cargo (pull_request) Successful in 44s
CI / docker (pull_request) Successful in 59s
2026-09-02 02:41:56 +00:00
Compare
Author
Owner

The blocker here is false, and I am the one who should have been asked six days ago.

The premise. A PROPFIND /dav/cal/ returning 401 as the fixture account was read as the directory is OIDC and cannot hold passwords. A 401 is returned by both that and this principal has no password set, so it could not have distinguished them.

The discriminating measurement is a different population, not a better request: whether any other principal carries one. x:Account/get against stalwart-mail-0, counts of entries, values never read:

total principals            26
NON-EMPTY credentials        6
EMPTY credentials           20

So the directory holds passwords. The precedent is the same shape as what this needs:

name=scratch-spf-probe      roles=User  aliases=0  groups=0  credEntries=1
name=sms-ingest             roles=User  aliases=0  groups=0  credEntries=1
name=caldav_test            roles=User  aliases=0  groups=0  credEntries=0
name=caldav_test_attendee   roles=User  aliases=0  groups=0  credEntries=0

scratch-spf-probe is a probe principal with a password, in this directory, created for this reason. The fixtures were never missing accounts. They were missing one field.

Both are outside the mail path, checked against the live outbound routing table rather than against the word "test" in their names: neither appears in it, and both have aliases=0 and groups=0. A credential on them cannot affect anyone's mail.

Dispatched to platform: Stalwart-local credential on those two principals, no OIDC exception, stored in 1Password, verified by a real PROPFIND round trip. Hazard named in the brief rather than discovered in a report — x:Account/set replaces the credentials object rather than merging into it, and julian has six entries.

The waiting-on-julian label is now wrong and should come off. Nothing here needs Julian: no directory exception is required, and he is not the one who lifts this. Leaving it puts a false entry on his queue in fleet-ledger.sh waiting, which is worse than no label. blocked is still right, and the blocker is now platform setting the credential.

If the PROPFIND still returns 401 with one set, that is a real directory finding and I want it in that form. Do not build a second acceptance path to get a green.

The blocker here is false, and I am the one who should have been asked six days ago. **The premise.** A `PROPFIND /dav/cal/` returning 401 as the fixture account was read as *the directory is OIDC and cannot hold passwords*. A 401 is returned by both that and *this principal has no password set*, so it could not have distinguished them. **The discriminating measurement** is a different population, not a better request: whether any *other* principal carries one. `x:Account/get` against `stalwart-mail-0`, counts of entries, values never read: total principals 26 NON-EMPTY credentials 6 EMPTY credentials 20 So the directory holds passwords. The precedent is the same shape as what this needs: name=scratch-spf-probe roles=User aliases=0 groups=0 credEntries=1 name=sms-ingest roles=User aliases=0 groups=0 credEntries=1 name=caldav_test roles=User aliases=0 groups=0 credEntries=0 name=caldav_test_attendee roles=User aliases=0 groups=0 credEntries=0 `scratch-spf-probe` is a probe principal with a password, in this directory, created for this reason. **The fixtures were never missing accounts. They were missing one field.** **Both are outside the mail path**, checked against the live outbound routing table rather than against the word "test" in their names: neither appears in it, and both have `aliases=0` and `groups=0`. A credential on them cannot affect anyone's mail. **Dispatched to `platform`**: Stalwart-local credential on those two principals, no OIDC exception, stored in 1Password, verified by a real `PROPFIND` round trip. Hazard named in the brief rather than discovered in a report — `x:Account/set` replaces the `credentials` object rather than merging into it, and `julian` has six entries. **The `waiting-on-julian` label is now wrong and should come off.** Nothing here needs Julian: no directory exception is required, and he is not the one who lifts this. Leaving it puts a false entry on his queue in `fleet-ledger.sh waiting`, which is worse than no label. `blocked` is still right, and the blocker is now `platform` setting the credential. **If the `PROPFIND` still returns 401 with one set, that is a real directory finding and I want it in that form.** Do not build a second acceptance path to get a green.
Author
Owner

Unblocked: PROPFIND /dav/cal/ returns 207 for both fixtures. And my mechanism in the comment above is wrong — correcting it here rather than editing it.

PROPFIND /dav/cal/  caldav_test@kampong.social            HTTP 207
PROPFIND /dav/cal/  caldav_test_attendee@kampong.social   HTTP 207
control, deliberately wrong password                      HTTP 401

What I got wrong

I wrote "so the directory holds passwords". It does not. x:Account/set on credentials is refused for these accounts:

{"type":"forbidden","description":"Cannot set credentials for accounts in an external directory."}

x:AppPassword/set is what works. So the original six-day belief was half right: the directory is external and cannot hold a primary password, and it holds an app password fine.

My count was a correct measurement supporting a wrong conclusion. "6 of 26 carry credentials" is true, and credentials conflates two slot types. The fact that actually predicts the outcome is the breakdown: four of those six hold only AppPassword slots, and only julian and reports have a Password slot, which predates the directory. I read a total where the composition was the answer.

platform found this by attempting the set on a throwaway principal rather than on a target, which is the only reason a forbidden response cost nothing.

A trap for whoever picks this up

The 1Password items that already existed for both fixtures are not Stalwart credentials. Both had passwords, 28 and 32 characters, while Stalwart reported credEntries=0 for both principals. Tested rather than assumed:

caldav_test with the OLD 1Password password    HTTP 401
a deliberately wrong password                  HTTP 401
scratch-spf-probe with its app password        HTTP 207

The old value is indistinguishable from a wrong one. The attendee item's URL is login.kampong.social, so at least that one is a Logto credential. Both old items were left untouched; two new ones were created, and their notes say which is which.

The credentials

op://Comte/Stalwart AppPassword caldav_test/password            length 40
op://Comte/Stalwart AppPassword caldav_test_attendee/password   length 40

caldav_test           credEntries 0 -> 1
caldav_test_attendee  credEntries 0 -> 1

Paths, since three of four guesses were wrong

/dav/cal/                 the one that works
/dav/                     404
/dav/cal/caldav_test/     404
/.well-known/caldav       307

Labels

Both blocked and waiting-on-julian should come off. Nothing here needed Julian at any point, and it is no longer blocked. Leaving waiting-on-julian puts a false entry on his queue in fleet-ledger.sh waiting.

**Unblocked: `PROPFIND /dav/cal/` returns 207 for both fixtures. And my mechanism in the comment above is wrong — correcting it here rather than editing it.** PROPFIND /dav/cal/ caldav_test@kampong.social HTTP 207 PROPFIND /dav/cal/ caldav_test_attendee@kampong.social HTTP 207 control, deliberately wrong password HTTP 401 ## What I got wrong I wrote *"so the directory holds passwords"*. It does not. `x:Account/set` on `credentials` is refused for these accounts: {"type":"forbidden","description":"Cannot set credentials for accounts in an external directory."} **`x:AppPassword/set` is what works.** So the original six-day belief was **half right**: the directory is external and cannot hold a *primary* password, and it holds an *app password* fine. **My count was a correct measurement supporting a wrong conclusion.** "6 of 26 carry credentials" is true, and `credentials` conflates two slot types. The fact that actually predicts the outcome is the breakdown: **four of those six hold only AppPassword slots**, and only `julian` and `reports` have a Password slot, which predates the directory. I read a total where the composition was the answer. `platform` found this by attempting the set **on a throwaway principal rather than on a target**, which is the only reason a forbidden response cost nothing. ## A trap for whoever picks this up **The 1Password items that already existed for both fixtures are not Stalwart credentials.** Both had passwords, 28 and 32 characters, while Stalwart reported `credEntries=0` for both principals. Tested rather than assumed: caldav_test with the OLD 1Password password HTTP 401 a deliberately wrong password HTTP 401 scratch-spf-probe with its app password HTTP 207 **The old value is indistinguishable from a wrong one.** The attendee item's URL is `login.kampong.social`, so at least that one is a Logto credential. Both old items were left untouched; two new ones were created, and their notes say which is which. ## The credentials op://Comte/Stalwart AppPassword caldav_test/password length 40 op://Comte/Stalwart AppPassword caldav_test_attendee/password length 40 caldav_test credEntries 0 -> 1 caldav_test_attendee credEntries 0 -> 1 ## Paths, since three of four guesses were wrong /dav/cal/ the one that works /dav/ 404 /dav/cal/caldav_test/ 404 /.well-known/caldav 307 ## Labels **Both `blocked` and `waiting-on-julian` should come off.** Nothing here needed Julian at any point, and it is no longer blocked. Leaving `waiting-on-julian` puts a false entry on his queue in `fleet-ledger.sh waiting`.
Author
Owner

The measurement ran and the hold is lifted. Two of my own refusals came out, and the review found two defects I put in.

Measurement: #16, issuecomment-17026. Executed against the deployed Stalwart in a collection created and deleted for the purpose, containment as committed, no fixture carrying ATTENDEE or ORGANIZER.

Stalwart matches an EXDATE by instant, not by literal form. So two refusals in this change, both pre-registered to be removed if the run went against them, are gone at 6317289:

  • the value-type refusal, because EXDATE:20260903T000000Z removes an occurrence of an all-day series
  • the first-occurrence refusal, because an EXDATE for DTSTART's own occurrence removed it, leaving five of six

The RANGE=THISANDFUTURE refusal stays. It is not a claim about the server: that parameter names a different operation, so excluding the occurrence is not what the caller asked for whatever the server would do.

The exclusion is now shaped to the caller's value rather than to DTSTART's parameters, because that combination became reachable when the value-type refusal went, and a date under a TZID= head is malformed however forgiving the matching is.

The cross-engine review, run on the diff and not on the code it repairs

Asked as can this new code do the wrong thing in the case it was written for. It found two, at 3139ebd, and both are the repair carrying a defect of the family it repaired.

The idempotency check compared values and ignored the zone. An existing EXDATE;TZID=UTC:20260908T090000 counted as already excluding the 09:00 Singapore occurrence. It does not, and the consequence was worse than a redundant write: the exclusion was skipped, the override was still removed, and the occurrence reappeared while the call reported success. That defect exists because the measurement changed the model — learning that matching is by instant retired the two refusals I had on trial and invalidated a third assumption I had not questioned.

remove_override stopped at the first match. Duplicate overrides for one occurrence left the rest orphaned by the new EXDATE, so a later read reports the occurrence the deletion claimed to remove. Duplicates are invalid iCalendar, and accepting them while preserving the contradictory half is the wrong direction for a deletion tool.

What is unchanged

The hazard is confirmed, only its cause was misdescribed. A wrong instant is still accepted with 201 and still excludes nothing, and a value the RRULE never generates is indistinguishable from a wrongly-zoned one. That is why an expander is the only possible pre-flight check, and it moves ahead of truncate_series as pre-registered.

recurrence_id is still trusted as given, and the tool description still says so.

Mutations

Every fix has a test that dies when it is reverted, and nothing else dies:

mutation died
date target loses VALUE=DATE the two shape tests
target always treated as a date-time the same two
RANGE check removed a_this_and_future_override_is_rejected_rather_than_excluded
idempotency ignores the zone an_exdate_in_a_different_zone_is_not_already_excluded
remove_override stops at the first match every_override_for_the_occurrence_is_removed_not_just_the_first

Gates

fmt --check, clippy -D warnings, test --all-features (141 + 9), audit, deny — green through build-slot.sh.

## The measurement ran and the hold is lifted. Two of my own refusals came out, and the review found two defects I put in. **Measurement:** #16, `issuecomment-17026`. Executed against the deployed Stalwart in a collection created and deleted for the purpose, containment as committed, no fixture carrying `ATTENDEE` or `ORGANIZER`. **Stalwart matches an `EXDATE` by instant, not by literal form.** So two refusals in this change, both pre-registered to be removed if the run went against them, are gone at `6317289`: - the **value-type** refusal, because `EXDATE:20260903T000000Z` removes an occurrence of an all-day series - the **first-occurrence** refusal, because an `EXDATE` for `DTSTART`'s own occurrence removed it, leaving five of six The `RANGE=THISANDFUTURE` refusal stays. It is not a claim about the server: that parameter names a different operation, so excluding the occurrence is not what the caller asked for whatever the server would do. The exclusion is now shaped to the **caller's** value rather than to `DTSTART`'s parameters, because that combination became reachable when the value-type refusal went, and a date under a `TZID=` head is malformed however forgiving the matching is. ## The cross-engine review, run on the diff and not on the code it repairs Asked as *can this new code do the wrong thing in the case it was written for*. It found two, at `3139ebd`, and both are the repair carrying a defect of the family it repaired. **The idempotency check compared values and ignored the zone.** An existing `EXDATE;TZID=UTC:20260908T090000` counted as already excluding the 09:00 Singapore occurrence. It does not, and the consequence was worse than a redundant write: **the exclusion was skipped, the override was still removed, and the occurrence reappeared while the call reported success.** That defect exists *because* the measurement changed the model — learning that matching is by instant retired the two refusals I had on trial and invalidated a third assumption I had not questioned. **`remove_override` stopped at the first match.** Duplicate overrides for one occurrence left the rest orphaned by the new `EXDATE`, so a later read reports the occurrence the deletion claimed to remove. Duplicates are invalid iCalendar, and accepting them while preserving the contradictory half is the wrong direction for a deletion tool. ## What is unchanged **The hazard is confirmed, only its cause was misdescribed.** A wrong instant is still accepted with 201 and still excludes nothing, and a value the `RRULE` never generates is indistinguishable from a wrongly-zoned one. That is why an expander is the only possible pre-flight check, and it moves ahead of `truncate_series` as pre-registered. `recurrence_id` is still trusted as given, and the tool description still says so. ## Mutations Every fix has a test that dies when it is reverted, and nothing else dies: | mutation | died | |---|---| | date target loses `VALUE=DATE` | the two shape tests | | target always treated as a date-time | the same two | | `RANGE` check removed | `a_this_and_future_override_is_rejected_rather_than_excluded` | | idempotency ignores the zone | `an_exdate_in_a_different_zone_is_not_already_excluded` | | `remove_override` stops at the first match | `every_override_for_the_occurrence_is_removed_not_just_the_first` | ## Gates `fmt --check`, `clippy -D warnings`, `test --all-features` (141 + 9), `audit`, `deny` — green through `build-slot.sh`.
jlxq0 force-pushed feat-delete-occurrence from 3139ebd10f
All checks were successful
CI / cargo (pull_request) Successful in 41s
CI / docker (pull_request) Successful in 49s
to 43313a995d
All checks were successful
CI / cargo (pull_request) Successful in 40s
CI / docker (pull_request) Successful in 51s
2026-09-02 03:46:32 +00:00
Compare
jlxq0 merged commit 79bb25f865 into main 2026-09-02 03:48:27 +00:00
jlxq0 deleted branch feat-delete-occurrence 2026-09-02 03:48:27 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jlxq0/caldav-mcp!19
No description provided.