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.
feat(mcp): add delete_occurrence, refusing when it would send calendar mail
All checks were successful
CI / cargo (pull_request) Successful in 36s
CI / docker (pull_request) Successful in 50s
4bac95df7b
Julian's requirement is to delete or change individual elements of a series.
This is the delete half, for series with no attendees.

The EXDATE's value type and TZID come from the master's DTSTART rather than
from the caller. RFC 5545 requires an exclusion to match the occurrence as the
RRULE generates it, and a mismatch is accepted by the server and excludes
nothing: a write that reports success and changes what the calendar shows not
at all. Taking both from DTSTART makes that unrepresentable rather than
documented.

The override for the excluded occurrence is removed in the same PUT. Left
behind it is an orphan — clients render an occurrence the series says does not
exist.

Three cases are refused rather than written, each because the server would
accept them 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 and an EXDATE for it is unreliable
- an occurrence whose override carries RANGE=THISANDFUTURE, which applies to
  that occurrence and every later one and is therefore a different operation

And it refuses when the object carries any ATTENDEE, naming every one. There is
no way to make this write quiet: Stalwart parses Schedule-Reply: F but consults
it only on DELETE, and SCHEDULE-AGENT=CLIENT is persistent data that would also
silence every later legitimate update. The caller opts in per call, because the
cost is paid per operation. Attendee addresses stay out of telemetry because
audit::error_class records a static class rather than the message.

recurrence_id is trusted as given. There is no RRULE expander here, so an
unmatched value writes an EXDATE that excludes nothing, and the tool
description says so rather than implying a validation that does not happen.

Refs #16
fix(caldav): correct what is_local means before it misleads an experiment
All checks were successful
CI / cargo (pull_request) Successful in 39s
CI / docker (pull_request) Successful in 53s
1e88356b57
The refusal message and its doc comment said Stalwart sends no iMIP to an
attendee hosted on itself, and that we cannot tell which domains those are.
Both halves are wrong, and verified so at v0.16.14 rather than relayed:

    // crates/groupware/src/scheduling/mod.rs
    let is_local = local_addresses.contains(&email);

`local_addresses` comes from `build_account_info`, which fills it with the
authenticated account's own addresses and its groups', each expanded across the
domain's names. It is an identity set, not the set of hosted domains —
`is_local_domain()` exists separately and is not what this uses. So the only
address `send_update_messages()` suppresses is the caller's own, and a second
mailbox on the same server is not local to the acting account and does receive
an iMIP.

The refusal now says to assume every named attendee is written to. That is the
same conservative behaviour as before, reached by a correct mechanism instead of
one that understated the risk.

The bad belief also had a live consequence: an experiment designed on it would
have expected no mail for a same-server attendee, so a correct run would have
looked like a bug. AGENTS.md says that in those words.

Refs #16
Merge main into feat-delete-occurrence
All checks were successful
CI / cargo (pull_request) Successful in 48s
CI / docker (pull_request) Successful in 1m3s
b96aa453f8
Keeps the held branch mergeable while it waits on the EXDATE measurement.
Resolving a nine-entry AGENTS.md conflict at the moment the hold lifts would
mean doing it under time pressure, and the conflict is purely additive: both
sides append to Known Pitfalls, so both sets are kept.

A merge rather than a rebase, so the branch needs no force push and spends no
approval on a history rewrite nobody is waiting for.

Nothing about #19's hold changes. delete_occurrence is still unmerged and the
measurement it waits on is still unrun.
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.
docs(agents): re-verify the scheduling findings at the running tag
All checks were successful
CI / cargo (pull_request) Successful in 46s
CI / docker (pull_request) Successful in 57s
d3f6bd2b25
The server was upgraded from 0.16.14 to 0.16.19 overnight, so the two findings
this branch rests on were attributed to a version that no longer runs while
still reading as "the deployed image".

Both hold unchanged at v0.16.19, checked by fetching at that tag rather than at
main: no_schedule_reply is read in delete.rs and nowhere else, with update.rs
and copy_move.rs at zero occurrences, and send_update_messages still requires
!self.email.is_local with Email::new setting it by exact membership.

So delete_occurrence's refusal stands on the code that is actually running.

And the general point is now beside the citation: a version in a finding is a
claim that expires when the deployment moves, and nothing reports the expiry.
Cite the version and the date, and re-check at the running tag, which means the
pod's imageID and then the same lines fetched at that tag.
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`.
fix(caldav): remove two refusals the measurement refuted
All checks were successful
CI / cargo (pull_request) Successful in 59s
CI / docker (pull_request) Successful in 1m14s
6317289f2b
Both were pre-registered on #16 to be removed if the run went against them, and
both did. Cited: issuecomment-17026, measured 2026-09-02 against the deployed
Stalwart in a collection created and deleted for the purpose.

The value-type refusal claimed that an EXDATE whose type differs from DTSTART
"excludes nothing". It excludes: EXDATE:20260903T000000Z removed an occurrence
of an all-day series, and EXDATE:20260908T010000Z removed one from a series
whose DTSTART carries TZID=Asia/Singapore. Stalwart matches by instant, not by
literal form.

The first-occurrence refusal rested on "some servers treat DTSTART as
implicitly included". Not this one: an EXDATE for the first occurrence removed
it, leaving five of six.

What survives is not a claim about the server. RANGE=THISANDFUTURE names a
different operation, so excluding that occurrence is not what the caller asked
for whatever the server would do with it.

The exclusion is now shaped to the caller's value rather than to DTSTART's
parameters. That combination became reachable when the value-type refusal went,
and a date under a TZID= head is malformed iCalendar, which no amount of
instant-matching repairs.

The hazard itself is confirmed rather than dismissed: 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.

Refs #16
fix(caldav): two defects the cross-engine review found in this change
All checks were successful
CI / cargo (pull_request) Successful in 41s
CI / docker (pull_request) Successful in 49s
3139ebd10f
Asked as "can this new code do the wrong thing in the case it was written for",
on the diff rather than on the code it repairs. Both findings are the repair
carrying a defect of the family it repaired.

The idempotency check compared EXDATE values and ignored the zone, so
EXDATE;TZID=UTC:20260908T090000 counted as already excluding the 09:00
Singapore occurrence. It does not: the server matches by instant and those are
different moments. 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. Now compares value and zone, which
errs toward writing a redundant exclusion rather than skipping a needed one.

That one exists because the measurement changed the model. Learning that
matching is by instant retired two refusals I had put on trial, and invalidated
a third assumption I had not questioned.

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

Both mutations kill exactly the test written for them and nothing else.

Refs #16
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.