assess: recurrence exception handling (EXDATE, overrides, series truncation) #16

Open
opened 2026-08-26 08:55:34 +00:00 by jlxq0 · 6 comments
Owner

Assessment of a forwarded specification for recurrence exception handling. No branch, no code. Relayed by mantis from another of Julian's sessions, so it is a well-informed proposal rather than a requirement.

Everything below was checked against Stalwart's source at the version we run and against this repository. Two of the proposal's load-bearing claims do not hold.

The version under test

stalwart-mail-0, container `stalwart`, image stalwartlabs/stalwart:v0.16.14
namespace `mail`, statefulset stalwart-mail

1. Schedule-Reply: F does not suppress anything on the operation that needs it

The proposal maps suppress_scheduling to Schedule-Reply: F and defaults it to on for occurrence deletions. Stalwart parses the header:

// crates/dav-proto/src/parser/header.rs
"Schedule-Reply" => {
    self.no_schedule_reply = value == "F";

and reads no_schedule_reply in exactly one file:

// crates/dav/src/calendar/delete.rs
let send_itip = self.core.groupware.itip_enabled
    && !headers.no_schedule_reply
    && !account_info.addresses().is_empty()
    && access_token.has_permission(Permission::CalendarSchedulingSend);

crates/dav/src/calendar/update.rs — the PUT path — never mentions it. Every occurrence operation in the proposal is a PUT. Adding EXDATE, removing an override, setting UNTIL: all read-modify-write on one href. So the header would be accepted, ignored, and the scheduling messages would go out anyway.

That is the exact hazard the proposal names — a silent accept — appearing in the mechanism proposed to prevent it, and with the one failure mode that cannot be undone.

Two further details:

  • The comparison is value == "F": exact, case-sensitive, and unlike the If-Schedule-Tag-Match arm three lines above it, not trimmed. A lowercase f is a silent no-op even on DELETE, where the header does work.
  • On DELETE Stalwart is broader than RFC 6638. The RFC defines Schedule-Reply as an Attendee-side control that suppresses the REPLY when an attendee deletes a scheduling object (§8.1), and defines no header that suppresses an Organizer's CANCEL. Stalwart's gate suppresses all iTIP for that delete, organizer CANCEL included. So Delete All can be suppressed with it. Occurrence-level operations cannot.

2. The sixty cancellations are not sixty, and they are not cancellations

An EXDATE added to the master is a changed entry on the main instance, and Stalwart's organizer-side diff emits:

// crates/groupware/src/scheduling/organizer.rs
changed_instances.extend(instance.attendees.iter().filter_map(|attendee| {
    if attendee.send_update_messages() {
        Some((instance_id, attendee.email.email.as_str(), &ICalendarMethod::Request))

REQUEST — a full update of the series — one per external attendee per PUT. Not a per-occurrence CANCEL. Per-instance CANCEL is emitted on a different path: when an override component present in the old object is absent from the new one.

So a month of exclusions done as thirty PUTs sends thirty re-invitations, and the same month batched into one PUT sends one. The proposal's own batching rule is the mitigation, and it is worth more than it claims. The arithmetic in the brief overstates the volume and understates how odd the messages are.

3. The switch that does work is SCHEDULE-AGENT, and it is data rather than a header

// crates/groupware/src/scheduling/snapshot.rs
pub fn send_update_messages(&self) -> bool {
    !self.email.is_local
        && self.is_server_scheduling
        && self.rsvp.is_none_or(|rsvp| rsvp)
        && (self.force_send.is_some()
            || self.part_stat.is_none_or(|ps| ps != &ICalendarParticipationStatus::Declined))
}

is_server_scheduling is set false when the ATTENDEE carries SCHEDULE-AGENT=CLIENT or SCHEDULE-AGENT=NONE. That is the only per-attendee suppression Stalwart honours on a PUT.

It is persistent. Writing it to suppress one batch of exclusions also suppresses every later legitimate update to that attendee until someone removes it. A suppress_scheduling: bool parameter that quietly rewrites ATTENDEE parameters would be a lasting change to the event wearing the costume of a per-call option, and the damage would show up weeks later as an attendee who stopped receiving updates.

!self.email.is_local is worth reading twice: if the attendee's address is hosted on this Stalwart, no iMIP is generated at all. Whether the named attendee is local decides whether any of this matters, and that is one lookup rather than an assumption. I have not done it, because it is Julian's data and the question can be answered without me guessing at an address.

4. What is already possible with what we have

  • update_event (src/caldav_client.rs:450) already does exactly the read-modify-write the proposal describes: GET the whole resource, patch, PUT back to the same href with If-Match from either the caller's etag or the GET's. The plumbing exists; only the iCalendar manipulation is missing.
  • patch_ics edits the first VEVENT and passes every later component through untouched, so today's update_event on a series carrying overrides does not destroy them. It also cannot address them.
  • parse_ical_events (src/caldav_client.rs:886) already returns one Event per VEVENT with a shared href, so on a non-expanded fetch the master and its overrides are already distinguishable by recurrence_id. is_override is derivable and master_href is the href itself.

5. The gap it identifies is real, and the cause is not ours

list_events issues a calendar-query with <c:expand> (src/caldav_client.rs:353), and search_events delegates to it. RFC 4791 §9.6.5 requires an expanded component to have its recurrence properties removed, so recurrence_rule: null on an instance is the server obeying the specification rather than our parser losing it. There is no non-expanded read path exposed as a tool, so today there is no way to obtain the master's RRULE, EXDATEs or RDATEs.

get_event_raw would expose something update_event already fetches on every call.

6. What I could not measure, and what it would take

Unmeasured, and both matter:

  • The silent-EXDATE claim. Structurally it looks right — a mismatched value type or TZID simply fails to match a generated occurrence, and nothing rejects it — but "looks right" is not a measurement, and this is the claim mantis most wants measured.
  • What Stalwart actually emits on each of these operations, as opposed to what the diff code reads like.

Both need writes to a calendar. I have no CalDAV credential of my own: this service forwards the caller's bearer, so reaching the DAV server means using Julian's token, and I will not experiment on his data to find out.

What would make it measurable safely is a throwaway Stalwart account with its own mailbox. Then an attendee can be a local test address, any iMIP lands in a mailbox we can read, and nothing can escape to a third party. That is an infrastructure request rather than something to create unilaterally, and until it exists the honest answer to "does Schedule-Reply work here" is the source reading above and not an experiment.

7. Work split

Small, no new server behaviour, no expansion needed:

  • get_event_raw(event_href) — expose the GET we already do.
  • master_href, is_override, and the master's recurrence_rule / exdates / rdates on expanded instances. Needs a second non-expanded fetch per series, or a calendar-multiget without <c:expand>.
  • truncate_series(event_href, until, etag?)UNTIL on the RRULE, strip later EXDATEs and overrides. Pure read-modify-write.
  • Idempotent EXDATE insertion, and removing the matching override in the same PUT.

Not small:

  • delete_occurrences_in_range needs to know which RECURRENCE-IDs a series actually generates, which means an RRULE/RDATE expander. We do not have one and it is the single biggest piece of this. Everything the proposal says about matching an EXDATE to the generated occurrence depends on it; without it, delete_occurrence can only trust the recurrence_id it is handed.
  • RANGE=THISANDFUTURE: supporting it needs the expander too. Rejecting it explicitly is small and should be the first version.
  • Anything that touches scheduling.

Blocked:

  • suppress_scheduling as specified. It cannot be built on Schedule-Reply for PUT operations, and building it on SCHEDULE-AGENT is a different feature with a persistent effect that needs to be a decision rather than a default.

The brief arrived truncated

The section "Tests worth writing first" is cut off mid-sentence at "a series spanning a". Everything above is assessed without it. mantis can obtain the rest; the missing section is the one most likely to change the work split, since it is the part that says what "correct" is supposed to look like.

Assessment of a forwarded specification for recurrence exception handling. **No branch, no code.** Relayed by `mantis` from another of Julian's sessions, so it is a well-informed proposal rather than a requirement. Everything below was checked against Stalwart's source at the version we run and against this repository. Two of the proposal's load-bearing claims do not hold. ## The version under test stalwart-mail-0, container `stalwart`, image stalwartlabs/stalwart:v0.16.14 namespace `mail`, statefulset stalwart-mail ## 1. `Schedule-Reply: F` does not suppress anything on the operation that needs it The proposal maps `suppress_scheduling` to `Schedule-Reply: F` and defaults it to on for occurrence deletions. Stalwart parses the header: ```rust // crates/dav-proto/src/parser/header.rs "Schedule-Reply" => { self.no_schedule_reply = value == "F"; ``` and reads `no_schedule_reply` in exactly one file: ```rust // crates/dav/src/calendar/delete.rs let send_itip = self.core.groupware.itip_enabled && !headers.no_schedule_reply && !account_info.addresses().is_empty() && access_token.has_permission(Permission::CalendarSchedulingSend); ``` `crates/dav/src/calendar/update.rs` — the `PUT` path — never mentions it. **Every occurrence operation in the proposal is a `PUT`.** Adding `EXDATE`, removing an override, setting `UNTIL`: all read-modify-write on one href. So the header would be accepted, ignored, and the scheduling messages would go out anyway. That is the exact hazard the proposal names — a silent accept — appearing in the mechanism proposed to prevent it, and with the one failure mode that cannot be undone. Two further details: - The comparison is `value == "F"`: exact, case-sensitive, and unlike the `If-Schedule-Tag-Match` arm three lines above it, not trimmed. A lowercase `f` is a silent no-op even on `DELETE`, where the header does work. - On `DELETE` Stalwart is *broader* than RFC 6638. The RFC defines `Schedule-Reply` as an **Attendee**-side control that suppresses the `REPLY` when an attendee deletes a scheduling object (§8.1), and defines no header that suppresses an Organizer's `CANCEL`. Stalwart's gate suppresses all iTIP for that delete, organizer `CANCEL` included. So *Delete All* can be suppressed with it. Occurrence-level operations cannot. ## 2. The sixty cancellations are not sixty, and they are not cancellations An `EXDATE` added to the master is a changed entry on the main instance, and Stalwart's organizer-side diff emits: ```rust // crates/groupware/src/scheduling/organizer.rs changed_instances.extend(instance.attendees.iter().filter_map(|attendee| { if attendee.send_update_messages() { Some((instance_id, attendee.email.email.as_str(), &ICalendarMethod::Request)) ``` `REQUEST` — a full update of the series — one per external attendee per `PUT`. Not a per-occurrence `CANCEL`. Per-instance `CANCEL` is emitted on a different path: when an override component present in the old object is absent from the new one. So a month of exclusions done as thirty `PUT`s sends thirty **re-invitations**, and the same month batched into one `PUT` sends **one**. The proposal's own batching rule is the mitigation, and it is worth more than it claims. The arithmetic in the brief overstates the volume and understates how odd the messages are. ## 3. The switch that does work is `SCHEDULE-AGENT`, and it is data rather than a header ```rust // crates/groupware/src/scheduling/snapshot.rs pub fn send_update_messages(&self) -> bool { !self.email.is_local && self.is_server_scheduling && self.rsvp.is_none_or(|rsvp| rsvp) && (self.force_send.is_some() || self.part_stat.is_none_or(|ps| ps != &ICalendarParticipationStatus::Declined)) } ``` `is_server_scheduling` is set false when the `ATTENDEE` carries `SCHEDULE-AGENT=CLIENT` or `SCHEDULE-AGENT=NONE`. That is the only per-attendee suppression Stalwart honours on a `PUT`. It is **persistent**. Writing it to suppress one batch of exclusions also suppresses every later legitimate update to that attendee until someone removes it. A `suppress_scheduling: bool` parameter that quietly rewrites `ATTENDEE` parameters would be a lasting change to the event wearing the costume of a per-call option, and the damage would show up weeks later as an attendee who stopped receiving updates. `!self.email.is_local` is worth reading twice: **if the attendee's address is hosted on this Stalwart, no iMIP is generated at all.** Whether the named attendee is local decides whether any of this matters, and that is one lookup rather than an assumption. I have not done it, because it is Julian's data and the question can be answered without me guessing at an address. ## 4. What is already possible with what we have - `update_event` (`src/caldav_client.rs:450`) already does exactly the read-modify-write the proposal describes: `GET` the whole resource, patch, `PUT` back to the same href with `If-Match` from either the caller's etag or the `GET`'s. The plumbing exists; only the iCalendar manipulation is missing. - `patch_ics` edits the **first** `VEVENT` and passes every later component through untouched, so today's `update_event` on a series carrying overrides does not destroy them. It also cannot address them. - `parse_ical_events` (`src/caldav_client.rs:886`) already returns one `Event` per `VEVENT` with a shared href, so on a non-expanded fetch the master and its overrides are already distinguishable by `recurrence_id`. `is_override` is derivable and `master_href` is the href itself. ## 5. The gap it identifies is real, and the cause is not ours `list_events` issues a `calendar-query` with `<c:expand>` (`src/caldav_client.rs:353`), and `search_events` delegates to it. RFC 4791 §9.6.5 requires an expanded component to have its recurrence properties removed, so `recurrence_rule: null` on an instance is the server obeying the specification rather than our parser losing it. There is no non-expanded read path exposed as a tool, so today there is no way to obtain the master's `RRULE`, `EXDATE`s or `RDATE`s. `get_event_raw` would expose something `update_event` already fetches on every call. ## 6. What I could not measure, and what it would take Unmeasured, and both matter: - **The silent-`EXDATE` claim.** Structurally it looks right — a mismatched value type or `TZID` simply fails to match a generated occurrence, and nothing rejects it — but "looks right" is not a measurement, and this is the claim `mantis` most wants measured. - **What Stalwart actually emits on each of these operations**, as opposed to what the diff code reads like. Both need writes to a calendar. I have no CalDAV credential of my own: this service forwards the caller's bearer, so reaching the DAV server means using Julian's token, and I will not experiment on his data to find out. What would make it measurable safely is **a throwaway Stalwart account with its own mailbox**. Then an attendee can be a local test address, any iMIP lands in a mailbox we can read, and nothing can escape to a third party. That is an infrastructure request rather than something to create unilaterally, and until it exists the honest answer to "does `Schedule-Reply` work here" is the source reading above and not an experiment. ## 7. Work split **Small, no new server behaviour, no expansion needed:** - `get_event_raw(event_href)` — expose the `GET` we already do. - `master_href`, `is_override`, and the master's `recurrence_rule` / `exdates` / `rdates` on expanded instances. Needs a second non-expanded fetch per series, or a `calendar-multiget` without `<c:expand>`. - `truncate_series(event_href, until, etag?)` — `UNTIL` on the `RRULE`, strip later `EXDATE`s and overrides. Pure read-modify-write. - Idempotent `EXDATE` insertion, and removing the matching override in the same `PUT`. **Not small:** - `delete_occurrences_in_range` needs to know which `RECURRENCE-ID`s a series actually generates, which means an `RRULE`/`RDATE` expander. We do not have one and it is the single biggest piece of this. Everything the proposal says about matching an `EXDATE` to the generated occurrence depends on it; without it, `delete_occurrence` can only trust the `recurrence_id` it is handed. - `RANGE=THISANDFUTURE`: supporting it needs the expander too. Rejecting it explicitly is small and should be the first version. - Anything that touches scheduling. **Blocked:** - `suppress_scheduling` as specified. It cannot be built on `Schedule-Reply` for `PUT` operations, and building it on `SCHEDULE-AGENT` is a different feature with a persistent effect that needs to be a decision rather than a default. ## The brief arrived truncated The section **"Tests worth writing first"** is cut off mid-sentence at *"a series spanning a"*. Everything above is assessed without it. `mantis` can obtain the rest; the missing section is the one most likely to change the work split, since it is the part that says what "correct" is supposed to look like.
Author
Owner

Build order, per Julian's actual requirement

"I need to be able to delete or potentially change individual elements of a series."

The forwarded document is one route to that, not the requirement. Checklist against the order mantis set:

  • Surface RANGE instead of dropping it — PR #17. The silent-ignore was on the read path and live: parse_ical_temporal read only TZID and VALUE, so a RANGE=THISANDFUTURE override was reported identically to a single-instance one. There is no write path that can accept a RANGE yet, so explicit rejection lands with the tools that could receive one, in the step below.
  • Fix update_event patching the wrong component — PR #17. Not in the original plan; found while assessing, and a live defect rather than a hypothetical one.
  • Verbatim recurrence_id_value / _tzid / _range — PR #17. Without these, an EXDATE built from our output is the silently-ignored EXDATE this issue is about.
  • get_event_raw, master_href, is_override, master recurrence_rule / exdates / rdates on expanded instances. is_override is now derivable from recurrence_id; the master's RRULE still needs a non-expanded fetch.
  • delete_occurrence and update_occurrence, series with no ATTENDEE only. No attendees means Stalwart generates no iTIP at all, so the whole scheduling hazard is absent. Not gated on an expander: the tool trusts the recurrence_id it is handed, and the tool description says so. That is exactly what a caller looking at an occurrence in a client already has.
  • Attended series, with batching — one Request per attendee for a batch rather than one per exclusion.
  • truncate_series.
  • delete_occurrences_in_range — waits for an RRULE/RDATE expander. It is the one that would do the wrong thing at scale.

Decision: a tool that would emit scheduling messages refuses by default

Recorded here rather than asked upward, and the code is the reason.

Schedule-Reply: F is inert on PUT and SCHEDULE-AGENT=CLIENT is persistent data that suppresses every future update to that attendee too. There is therefore no way to make one of these operations quiet. The only honest options are "send the messages" and "do not touch the event", so the tool offers exactly those: refuse when the master or any override carries an ATTENDEE, unless the call passes an explicit per-call opt-in, and name the attendees in the refusal.

Per call, not per session or per client: the cost is paid per operation, so the consent is too.

One correction to how the refusal should be worded, from snapshot.rs: send_update_messages() requires !email.is_local, so an attendee hosted on this same Stalwart generates no iMIP. We cannot tell local from external without knowing which domains the server hosts, so the refusal names every attendee and says the server decides which are actually written to. Naming too many is a recoverable error; naming too few is the one that puts mail in a stranger's inbox.

Blocked on measurement

The silent-EXDATE claim is measured against the throwaway account Clark is creating, before any code that depends on an EXDATE landing. Nothing above depends on it except the two occurrence tools, and those are behind it.

## Build order, per Julian's actual requirement > "I need to be able to delete or potentially change individual elements of a series." The forwarded document is one route to that, not the requirement. Checklist against the order `mantis` set: - [x] **Surface `RANGE` instead of dropping it** — PR #17. The silent-ignore was on the *read* path and live: `parse_ical_temporal` read only `TZID` and `VALUE`, so a `RANGE=THISANDFUTURE` override was reported identically to a single-instance one. There is no write path that can accept a `RANGE` yet, so explicit rejection lands with the tools that could receive one, in the step below. - [x] **Fix `update_event` patching the wrong component** — PR #17. Not in the original plan; found while assessing, and a live defect rather than a hypothetical one. - [x] **Verbatim `recurrence_id_value` / `_tzid` / `_range`** — PR #17. Without these, an `EXDATE` built from our output is the silently-ignored `EXDATE` this issue is about. - [ ] **`get_event_raw`, `master_href`, `is_override`, master `recurrence_rule` / `exdates` / `rdates` on expanded instances.** `is_override` is now derivable from `recurrence_id`; the master's `RRULE` still needs a non-expanded fetch. - [ ] **`delete_occurrence` and `update_occurrence`, series with no `ATTENDEE` only.** No attendees means Stalwart generates no iTIP at all, so the whole scheduling hazard is absent. **Not gated on an expander:** the tool trusts the `recurrence_id` it is handed, and the tool description says so. That is exactly what a caller looking at an occurrence in a client already has. - [ ] **Attended series, with batching** — one `Request` per attendee for a batch rather than one per exclusion. - [ ] **`truncate_series`.** - [ ] **`delete_occurrences_in_range`** — waits for an `RRULE`/`RDATE` expander. It is the one that would do the wrong thing at scale. ## Decision: a tool that would emit scheduling messages refuses by default Recorded here rather than asked upward, and the code is the reason. `Schedule-Reply: F` is inert on `PUT` and `SCHEDULE-AGENT=CLIENT` is persistent data that suppresses every *future* update to that attendee too. There is therefore **no way to make one of these operations quiet**. The only honest options are "send the messages" and "do not touch the event", so the tool offers exactly those: refuse when the master or any override carries an `ATTENDEE`, unless the call passes an explicit per-call opt-in, and name the attendees in the refusal. Per call, not per session or per client: the cost is paid per operation, so the consent is too. One correction to how the refusal should be worded, from `snapshot.rs`: `send_update_messages()` requires `!email.is_local`, so an attendee hosted on this same Stalwart generates no iMIP. We cannot tell local from external without knowing which domains the server hosts, so the refusal names **every** attendee and says the server decides which are actually written to. Naming too many is a recoverable error; naming too few is the one that puts mail in a stranger's inbox. ## Blocked on measurement The silent-`EXDATE` claim is measured against the throwaway account Clark is creating, **before** any code that depends on an `EXDATE` landing. Nothing above depends on it except the two occurrence tools, and those are behind it.
Author
Owner

Written before the test account exists, so that running it is execution rather than design. A measurement designed after the data arrives can be shaped by it, and this one decides whether three of the refusals in PR #19 are necessary or superstition.

The claim under test

An EXDATE that does not match the occurrence as the RRULE generates it — wrong value type, wrong TZID, a UTC instant against a zoned DTSTARTis accepted by the server and excludes nothing. The PUT returns 2xx and the occurrence still renders.

Instrument

curl directly against Stalwart, not through caldav-mcp. Going through our own client puts our parser in the loop, and our parser is not the subject. Every request below is raw HTTP against the DAV base URL with the test account's credentials.

Two reads, answering two different questions, and both are needed:

read question call
A. stored? did the PUT persist the EXDATE? GET the resource, look for the EXDATE line
B. honoured? does the server act on it? REPORT calendar-query with <c:expand> over a window containing the occurrence, and look for its RECURRENCE-ID

Read B is the decisive one because the server performs the expansion, so it reports the server's own opinion of the recurrence set rather than ours. Read A alone proves storage, which is not the claim.

The failure under test is exactly: A says present, B says still there.

Instances are counted from the raw multistatus XML by counting BEGIN:VEVENT and extracting RECURRENCE-ID values with grep, never by parsing with our own code.

Safety

Dedicated account, a calendar created for this and deleted after. No fixture carries ATTENDEE or ORGANIZER. With no attendees Stalwart generates no iTIP at all on any path, so no outcome of this experiment can put mail anywhere. That is a property of the fixtures, not a flag I have to remember to pass.

Consequently this experiment measures nothing about scheduling. Those claims need a second experiment with an attendee on a second local mailbox, designed separately; bundling them would mean a fixture with an ATTENDEE in it, and the whole reason this one is safe is that no such fixture exists.

Fixtures, one resource per case

Each case gets its own .ics resource so no case can contaminate another. EXDATEs accumulate, and restoring between cases is a step that can be skipped by accident.

S-TZ — timed, zoned. DTSTART;TZID=Asia/Singapore:20260901T090000, DTEND;TZID=Asia/Singapore:20260901T093000, RRULE:FREQ=WEEKLY;COUNT=6. Occurrences: 1, 8, 15, 22, 29 September and 6 October, all 09:00 SGT.

S-DATE — all-day. DTSTART;VALUE=DATE:20260901, DTEND;VALUE=DATE:20260902, RRULE:FREQ=DAILY;COUNT=6.

S-FLOAT — floating, no zone. DTSTART:20260901T090000, RRULE:FREQ=WEEKLY;COUNT=6. Included because "same value type and TZID as DTSTART" has three forms, not two, and the floating one is the form nobody tests.

Step 0, before any EXDATE: read what the server generates

REPORT with <c:expand start="20260901T000000Z" end="20261007T000000Z"/> over each series and record the exact RECURRENCE-ID values the server emits — value type, TZID parameter or Z suffix, and count.

This is the baseline every later read is compared against, and it answers on its own the question the whole rule rests on: what form does this server generate? If Stalwart emits RECURRENCE-ID;TZID=Asia/Singapore:20260908T090000, then a UTC-instant EXDATE is a different value and the rule holds by construction. If it emits UTC instants, the rule is different from what the specification assumes.

Cases

Every case: PUT the fixture, PUT again with the EXDATE added, then read A and read B.

case series EXDATE written prediction
C1 positive control S-TZ EXDATE;TZID=Asia/Singapore:20260908T090000 5 instances, 8 Sep gone
C2 positive control S-DATE EXDATE;VALUE=DATE:20260903 5 instances, 3 Sep gone
C3 positive control S-FLOAT EXDATE:20260908T090000 5 instances, 8 Sep gone
W1 UTC against zoned S-TZ EXDATE:20260908T010000Z — the same instant, different form 6 instances, nothing excluded, PUT 2xx
W2 date-time against all-day S-DATE EXDATE:20260903T000000 6 instances, nothing excluded, PUT 2xx
W3 right form, wrong zone S-TZ EXDATE;TZID=UTC:20260908T090000 6 instances, nothing excluded, PUT 2xx
W4 right form, ungenerated value S-TZ EXDATE;TZID=Asia/Singapore:20260909T090000 (a Wednesday; the series is Tuesdays) 6 instances — and this is the control that says "excludes nothing" is indistinguishable from "wrong value"
F1 first occurrence S-TZ EXDATE;TZID=Asia/Singapore:20260901T090000 unknown — this is the one I expect to be wrong

C1–C3 are the reason the null results mean anything. Without seeing the instrument move, "6 instances" could be a wrong expansion window, a malformed REPORT, or a series that never generated that occurrence. If a positive control fails, every W result is void and the experiment stops until it passes.

F1 decides a refusal in PR #19. That refusal is currently justified by "some servers treat DTSTART as implicitly included", which is received wisdom, not a measurement of this server. If Stalwart honours it, the refusal is superstition and comes out.

W4 exists to keep me honest. If W1–W3 show "nothing excluded", W4 shows the same thing for a reason nobody disputes. The three wrong-form cases are only interesting if they are indistinguishable from W4 — which is the point: the server cannot tell you which kind of nothing happened.

What a 2xx with the occurrence still rendering looks like

Recorded here before seeing it, so it is recognisable rather than rationalised afterwards.

  • PUT returns 204 No Content with a new ETag differing from the one sent in If-Match. No body, no warning header, no precondition-failed.
  • Read A shows the EXDATE line present in the stored object, byte-for-byte as written.
  • Read B returns the same instance count as the baseline, including a RECURRENCE-ID for the occurrence supposedly excluded.

Nothing in the PUT response distinguishes this from a successful exclusion. That is the finding, if it holds, and it is why read B is mandatory rather than a nicety: the write is a success by every signal the writing client can see.

Also recorded, in case the prediction is wrong in the other direction: if Stalwart rejects a malformed EXDATE, the interesting output is the status and any <D:error> condition element, since a server that validates makes three of PR #19's refusals unnecessary.

What each outcome changes

outcome consequence
W1–W3 accepted and ignored PR #19's value-type and TZID-from-DTSTART design is necessary; merge it
W1–W3 rejected by the server the design is still right but the refusals are belt-and-braces; say so in the tool description rather than implying we are the only guard
F1 honoured the first-occurrence refusal is superstition; remove it
F1 accepted and ignored the refusal stays, and now has a measurement behind it rather than folklore
any positive control fails experiment void, stop, fix the instrument

Cleanup

Delete the calendar. Record the results as a comment on this issue with the raw status lines and instance counts, not a summary of them.

Written **before** the test account exists, so that running it is execution rather than design. A measurement designed after the data arrives can be shaped by it, and this one decides whether three of the refusals in PR #19 are necessary or superstition. ## The claim under test > An `EXDATE` that does not match the occurrence as the `RRULE` generates it — wrong value type, wrong `TZID`, a UTC instant against a zoned `DTSTART` — **is accepted by the server and excludes nothing.** The `PUT` returns 2xx and the occurrence still renders. ## Instrument **`curl` directly against Stalwart, not through `caldav-mcp`.** Going through our own client puts our parser in the loop, and our parser is not the subject. Every request below is raw HTTP against the DAV base URL with the test account's credentials. Two reads, answering two different questions, and both are needed: | read | question | call | |---|---|---| | **A. stored?** | did the `PUT` persist the `EXDATE`? | `GET` the resource, look for the `EXDATE` line | | **B. honoured?** | does the server act on it? | `REPORT` `calendar-query` with `<c:expand>` over a window containing the occurrence, and look for its `RECURRENCE-ID` | Read B is the decisive one because the **server** performs the expansion, so it reports the server's own opinion of the recurrence set rather than ours. Read A alone proves storage, which is not the claim. The failure under test is exactly: **A says present, B says still there.** Instances are counted from the raw multistatus XML by counting `BEGIN:VEVENT` and extracting `RECURRENCE-ID` values with `grep`, never by parsing with our own code. ## Safety Dedicated account, a calendar created for this and deleted after. **No fixture carries `ATTENDEE` or `ORGANIZER`.** With no attendees Stalwart generates no iTIP at all on any path, so no outcome of this experiment can put mail anywhere. That is a property of the fixtures, not a flag I have to remember to pass. Consequently this experiment measures **nothing** about scheduling. Those claims need a second experiment with an attendee on a second local mailbox, designed separately; bundling them would mean a fixture with an `ATTENDEE` in it, and the whole reason this one is safe is that no such fixture exists. ## Fixtures, one resource per case Each case gets its own `.ics` resource so no case can contaminate another. `EXDATE`s accumulate, and restoring between cases is a step that can be skipped by accident. **S-TZ — timed, zoned.** `DTSTART;TZID=Asia/Singapore:20260901T090000`, `DTEND;TZID=Asia/Singapore:20260901T093000`, `RRULE:FREQ=WEEKLY;COUNT=6`. Occurrences: 1, 8, 15, 22, 29 September and 6 October, all 09:00 SGT. **S-DATE — all-day.** `DTSTART;VALUE=DATE:20260901`, `DTEND;VALUE=DATE:20260902`, `RRULE:FREQ=DAILY;COUNT=6`. **S-FLOAT — floating, no zone.** `DTSTART:20260901T090000`, `RRULE:FREQ=WEEKLY;COUNT=6`. Included because "same value type and `TZID` as `DTSTART`" has three forms, not two, and the floating one is the form nobody tests. ## Step 0, before any `EXDATE`: read what the server generates `REPORT` with `<c:expand start="20260901T000000Z" end="20261007T000000Z"/>` over each series and **record the exact `RECURRENCE-ID` values the server emits** — value type, `TZID` parameter or `Z` suffix, and count. This is the baseline every later read is compared against, and it answers on its own the question the whole rule rests on: *what form does this server generate?* If Stalwart emits `RECURRENCE-ID;TZID=Asia/Singapore:20260908T090000`, then a UTC-instant `EXDATE` is a different value and the rule holds by construction. If it emits UTC instants, the rule is different from what the specification assumes. ## Cases Every case: `PUT` the fixture, `PUT` again with the `EXDATE` added, then read A and read B. | case | series | `EXDATE` written | prediction | |---|---|---|---| | **C1** positive control | S-TZ | `EXDATE;TZID=Asia/Singapore:20260908T090000` | 5 instances, 8 Sep gone | | **C2** positive control | S-DATE | `EXDATE;VALUE=DATE:20260903` | 5 instances, 3 Sep gone | | **C3** positive control | S-FLOAT | `EXDATE:20260908T090000` | 5 instances, 8 Sep gone | | **W1** UTC against zoned | S-TZ | `EXDATE:20260908T010000Z` — the same instant, different form | 6 instances, nothing excluded, `PUT` 2xx | | **W2** date-time against all-day | S-DATE | `EXDATE:20260903T000000` | 6 instances, nothing excluded, `PUT` 2xx | | **W3** right form, wrong zone | S-TZ | `EXDATE;TZID=UTC:20260908T090000` | 6 instances, nothing excluded, `PUT` 2xx | | **W4** right form, ungenerated value | S-TZ | `EXDATE;TZID=Asia/Singapore:20260909T090000` (a Wednesday; the series is Tuesdays) | 6 instances — and this is the control that says "excludes nothing" is indistinguishable from "wrong value" | | **F1** first occurrence | S-TZ | `EXDATE;TZID=Asia/Singapore:20260901T090000` | **unknown** — this is the one I expect to be wrong | **C1–C3 are the reason the null results mean anything.** Without seeing the instrument move, "6 instances" could be a wrong expansion window, a malformed `REPORT`, or a series that never generated that occurrence. If a positive control fails, every W result is void and the experiment stops until it passes. **F1 decides a refusal in PR #19.** That refusal is currently justified by "some servers treat `DTSTART` as implicitly included", which is received wisdom, not a measurement of this server. If Stalwart honours it, the refusal is superstition and comes out. **W4 exists to keep me honest.** If W1–W3 show "nothing excluded", W4 shows the same thing for a reason nobody disputes. The three wrong-form cases are only interesting if they are indistinguishable from W4 — which is the point: the server cannot tell you which kind of nothing happened. ## What a 2xx with the occurrence still rendering looks like Recorded here before seeing it, so it is recognisable rather than rationalised afterwards. - `PUT` returns **`204 No Content`** with a **new `ETag`** differing from the one sent in `If-Match`. No body, no warning header, no `precondition-failed`. - Read A shows the `EXDATE` line present in the stored object, byte-for-byte as written. - Read B returns the **same instance count as the baseline**, including a `RECURRENCE-ID` for the occurrence supposedly excluded. **Nothing in the `PUT` response distinguishes this from a successful exclusion.** That is the finding, if it holds, and it is why read B is mandatory rather than a nicety: the write is a success by every signal the writing client can see. Also recorded, in case the prediction is wrong in the other direction: if Stalwart **rejects** a malformed `EXDATE`, the interesting output is the status and any `<D:error>` condition element, since a server that validates makes three of PR #19's refusals unnecessary. ## What each outcome changes | outcome | consequence | |---|---| | W1–W3 accepted and ignored | PR #19's value-type and `TZID`-from-`DTSTART` design is necessary; merge it | | W1–W3 rejected by the server | the design is still right but the refusals are belt-and-braces; say so in the tool description rather than implying we are the only guard | | F1 honoured | the first-occurrence refusal is superstition; remove it | | F1 accepted and ignored | the refusal stays, and now has a measurement behind it rather than folklore | | any positive control fails | experiment void, stop, fix the instrument | ## Cleanup Delete the calendar. Record the results as a comment on this issue with the raw status lines and instance counts, not a summary of them.
Author
Owner

Addendum to the design above, all of it fixed before the account exists.

Pre-registered consequences, per case

The earlier table grouped cases. Grouping is where interpretation hides, so here is one row per case, each naming the code change it causes. One refusal in PR #19 is on trial and that is exactly where the pressure to read a result charitably applies, so the reading is decided now.

case result what it causes
C1/C2/C3 control instance gone proceed; the instrument moves
C1/C2/C3 control instance still there stop. Every W and F result is void. Fix the instrument, re-run, change no code
W1 UTC vs zoned accepted, not excluded EXDATE shape from DTSTART is load-bearing; PR #19 merges as written
W1 rejected by the server shape logic stays, but the tool description stops implying we are the only guard, and the refusal is documented as belt-and-braces
W1 excluded anyway Stalwart normalises before matching. The value-type refusal in #19 is superstition for this server and comes out, with the measurement cited
W2 date-time vs all-day accepted, not excluded the all-day branch of the shape logic is load-bearing
W2 excluded anyway the VALUE=DATE branch comes out; keep it only if W1 and W3 disagree with W2
W3 wrong TZID accepted, not excluded the TZID-from-DTSTART decision is load-bearing and the caller must never supply it
W3 excluded anyway Stalwart compares instants rather than values; then only value type matters, not zone, and the doc comment says that instead of what it says now
W4 ungenerated value accepted, not excluded this is the row that moves the roadmap. See below
W4 rejected by the server the server validates against the generated set, and delete_occurrence needs no expander at all — it can trust the server to refuse
F1 first occurrence excluded the first-occurrence refusal is superstition on this server and comes out, with the sha of this comment cited in the removal
F1 accepted, not excluded the refusal stays and stops being folklore
F1 rejected by the server the refusal stays, and becomes a nicer error rather than a guess

Why W4 is the row that reorders the work

If a correctly-shaped EXDATE for a value the RRULE never generates is accepted and silently does nothing — and it is indistinguishable from W1–W3 — then there is no server-side signal that an exclusion was pointless, and a client cannot discover its own mistake from any response.

That makes the RRULE expander the only possible pre-flight check, which promotes it from "needed for delete_occurrences_in_range" to "the only way delete_occurrence can tell a caller their exclusion did nothing". It does not block PR #19 — the tool already says it trusts recurrence_id as given — but it moves the expander ahead of truncate_series and of the attended-series work in the order on this issue.

Pre-registering that now, because after seeing the data the temptation is to file it as a known limitation and carry on.

The positive control's form is chosen by Step 0, and the rule is fixed now

If Stalwart's expansion emits RECURRENCE-ID;TZID=Asia/Singapore:20260908T090000, C1 writes EXDATE;TZID=Asia/Singapore:20260908T090000. If it emits RECURRENCE-ID:20260908T010000Z, C1 writes that instead — and W1 and C1 swap roles, because "the form the server generates" is what the rule is about, not the form I guessed.

Fixing the literal in advance would give a positive control that fails for the wrong reason and void the run. Fixing the rule in advance — the control uses whatever Step 0 observed — is what keeps it a pre-registration.

Free observation while in there: does an ineffective PUT bump SEQUENCE?

Costs one extra grep per case and it is the justification for the idempotency refusal in PR #19.

Recorded per case: SEQUENCE before the write, SEQUENCE after, and the ETag before and after.

  • If a PUT storing an EXDATE that excludes nothing still bumps SEQUENCE, then a no-op write is a write whose only effect is the side effect — and on an attended series that side effect is mail for a change that did not happen. The idempotency short-circuit in delete_occurrence stops being a nicety and becomes the thing that prevents sending mail about nothing.
  • If it does not bump SEQUENCE, the short-circuit still saves a round trip and an ETag churn, and I will say that is all it saves rather than claiming more.

Either way the ETag will change, because the bytes changed. ETag is therefore not evidence about anything here and is recorded only so nobody later reads a changed ETag as an exclusion having landed.

Second mailbox

Requested for the scheduling experiment so it is not blocked a second time. It is not used by this one, and no fixture here will carry an ATTENDEE — the absence is what makes this experiment safe, rather than any flag I have to remember to pass.

Addendum to the design above, all of it fixed before the account exists. ## Pre-registered consequences, per case The earlier table grouped cases. Grouping is where interpretation hides, so here is one row per case, each naming the code change it causes. **One refusal in PR #19 is on trial and that is exactly where the pressure to read a result charitably applies**, so the reading is decided now. | case | result | what it causes | |---|---|---| | **C1/C2/C3** control | instance gone | proceed; the instrument moves | | **C1/C2/C3** control | instance still there | **stop.** Every W and F result is void. Fix the instrument, re-run, change no code | | **W1** UTC vs zoned | accepted, not excluded | `EXDATE` shape from `DTSTART` is load-bearing; PR #19 merges as written | | **W1** | rejected by the server | shape logic stays, but the tool description stops implying we are the only guard, and the refusal is documented as belt-and-braces | | **W1** | **excluded anyway** | Stalwart normalises before matching. The value-type refusal in #19 is superstition for this server and **comes out**, with the measurement cited | | **W2** date-time vs all-day | accepted, not excluded | the all-day branch of the shape logic is load-bearing | | **W2** | excluded anyway | the `VALUE=DATE` branch comes out; keep it only if W1 and W3 disagree with W2 | | **W3** wrong `TZID` | accepted, not excluded | the `TZID`-from-`DTSTART` decision is load-bearing and the caller must never supply it | | **W3** | excluded anyway | Stalwart compares instants rather than values; then only value *type* matters, not zone, and the doc comment says that instead of what it says now | | **W4** ungenerated value | accepted, not excluded | **this is the row that moves the roadmap.** See below | | **W4** | rejected by the server | the server validates against the generated set, and `delete_occurrence` needs no expander at all — it can trust the server to refuse | | **F1** first occurrence | excluded | the first-occurrence refusal is **superstition on this server and comes out**, with the sha of this comment cited in the removal | | **F1** | accepted, not excluded | the refusal stays and stops being folklore | | **F1** | rejected by the server | the refusal stays, and becomes a nicer error rather than a guess | ## Why W4 is the row that reorders the work If a correctly-shaped `EXDATE` for a value the `RRULE` never generates is accepted and silently does nothing — and it is indistinguishable from W1–W3 — then **there is no server-side signal that an exclusion was pointless**, and a client cannot discover its own mistake from any response. That makes the `RRULE` expander the *only* possible pre-flight check, which promotes it from "needed for `delete_occurrences_in_range`" to "the only way `delete_occurrence` can tell a caller their exclusion did nothing". It does not block PR #19 — the tool already says it trusts `recurrence_id` as given — but it moves the expander ahead of `truncate_series` and of the attended-series work in the order on this issue. Pre-registering that now, because after seeing the data the temptation is to file it as a known limitation and carry on. ## The positive control's form is chosen by Step 0, and the rule is fixed now If Stalwart's expansion emits `RECURRENCE-ID;TZID=Asia/Singapore:20260908T090000`, C1 writes `EXDATE;TZID=Asia/Singapore:20260908T090000`. If it emits `RECURRENCE-ID:20260908T010000Z`, C1 writes that instead — **and W1 and C1 swap roles**, because "the form the server generates" is what the rule is about, not the form I guessed. Fixing the literal in advance would give a positive control that fails for the wrong reason and void the run. Fixing the *rule* in advance — the control uses whatever Step 0 observed — is what keeps it a pre-registration. ## Free observation while in there: does an ineffective `PUT` bump `SEQUENCE`? Costs one extra `grep` per case and it is the justification for the idempotency refusal in PR #19. Recorded per case: `SEQUENCE` before the write, `SEQUENCE` after, and the `ETag` before and after. - If a `PUT` storing an `EXDATE` that excludes nothing still bumps `SEQUENCE`, then a no-op write is a write whose only effect is the side effect — and on an attended series that side effect is mail for a change that did not happen. The idempotency short-circuit in `delete_occurrence` stops being a nicety and becomes the thing that prevents sending mail about nothing. - If it does not bump `SEQUENCE`, the short-circuit still saves a round trip and an `ETag` churn, and I will say that is all it saves rather than claiming more. Either way the `ETag` will change, because the bytes changed. `ETag` is therefore not evidence about anything here and is recorded only so nobody later reads a changed `ETag` as an exclusion having landed. ## Second mailbox Requested for the scheduling experiment so it is not blocked a second time. It is not used by this one, and no fixture here will carry an `ATTENDEE` — the absence is what makes this experiment safe, rather than any flag I have to remember to pass.
Author
Owner

Correction: two comments above are wrong about is_local, and the code was too

Verified from source at v0.16.14 rather than relayed, because it changes what an experiment expects to see.

I wrote, twice above and in delete_occurrence's refusal message, that Stalwart sends no iMIP to an attendee hosted on itself and that we cannot tell which domains those are. Both halves are wrong.

// crates/groupware/src/scheduling/mod.rs
pub fn new(email: &str, local_addresses: &[String]) -> Option<Self> {
    ...
    let is_local = local_addresses.contains(&email);

Exact string membership. And local_addresses comes from build_account_info in crates/common/src/cache/principals.rs, which fills it with the authenticated account's own addresses and its groups' addresses, each expanded across the domain's names — plus the calendar owner's, when acting on someone else's calendar. It is an identity set, not the set of hosted domains. is_local_domain() exists separately and is not what this uses.

So !email.is_local means "not one of my own addresses". It exists to stop the organiser mailing themself. A second mailbox on the same Stalwart is not local to the acting account and does receive an iMIP.

What it changes here, and what it does not

It does not change this experiment's safety. No fixture in it carries an ATTENDEE or ORGANIZER, so no iTIP is generated on any path regardless of what is_local means. Safety was a property of the fixtures rather than of the belief, which is why the wrong belief cost nothing here.

It does not change this experiment's pre-registration either, and I am not amending it to expect an iMIP. There are no attendees in it, so no iMIP is expected and its absence is not a signal — writing "expect mail" into a document whose fixtures cannot generate mail would make the run un-interpretable. The amendment belongs to the scheduling experiment, which is a separate design waiting on the second mailbox, and it is recorded here so it is right the first time:

Scheduling experiment, pre-registered now: with caldav_test_attendee@kampong.social as an ATTENDEE and the fixture owned by caldav-test@kampong.social, an iMIP is expected. Its arrival in the attendee's mailbox is a pass. Its absence is a failure condition — it would mean the mechanism is not what the source says, and every conclusion drawn from that source about suppression would be unreliable. The message never leaves the server: it enters the ordinary SMTP queue and the live route sends is_local_domain(rcpt_domain) to local before any mx fallthrough.

It does change the code, at 1e88356. The refusal message said Stalwart decides which attendees are actually mailed and that one hosted on it receives no message. It now says to assume every named attendee is written to, because the only address suppressed is the caller's own. Same conservative behaviour, reached by a correct mechanism rather than one that understated the risk by exactly the population most likely to be in a private calendar — colleagues on the same server.

The general shape, since it nearly cost a run

The belief was wrong and its conclusion — name every attendee, refuse by default — was right anyway. That is the kind of correction that gets skipped as academic. It was not academic, because someone was about to observe something because of it: an experiment built on it expects no mail for a same-server attendee, so a correct run would have looked like a bug and the finding would have been "Stalwart's suppression does not work" rather than "my model of it was wrong".

When correcting a mechanism whose conclusion holds anyway, the question is who is about to make an observation that depends on it.

## Correction: two comments above are wrong about `is_local`, and the code was too Verified from source at v0.16.14 rather than relayed, because it changes what an experiment expects to see. I wrote, twice above and in `delete_occurrence`'s refusal message, that Stalwart sends no iMIP to an attendee hosted on itself and that we cannot tell which domains those are. Both halves are wrong. ```rust // crates/groupware/src/scheduling/mod.rs pub fn new(email: &str, local_addresses: &[String]) -> Option<Self> { ... let is_local = local_addresses.contains(&email); ``` Exact string membership. And `local_addresses` comes from `build_account_info` in `crates/common/src/cache/principals.rs`, which fills it with **the authenticated account's own addresses and its groups' addresses**, each expanded across the domain's names — plus the calendar owner's, when acting on someone else's calendar. It is an identity set, not the set of hosted domains. `is_local_domain()` exists separately and is not what this uses. So `!email.is_local` means "not one of my own addresses". It exists to stop the organiser mailing themself. **A second mailbox on the same Stalwart is not local to the acting account and does receive an iMIP.** ## What it changes here, and what it does not **It does not change this experiment's safety.** No fixture in it carries an `ATTENDEE` or `ORGANIZER`, so no iTIP is generated on any path regardless of what `is_local` means. Safety was a property of the fixtures rather than of the belief, which is why the wrong belief cost nothing here. **It does not change this experiment's pre-registration either, and I am not amending it to expect an iMIP.** There are no attendees in it, so no iMIP is expected and its absence is not a signal — writing "expect mail" into a document whose fixtures cannot generate mail would make the run un-interpretable. The amendment belongs to the *scheduling* experiment, which is a separate design waiting on the second mailbox, and it is recorded here so it is right the first time: > **Scheduling experiment, pre-registered now:** with `caldav_test_attendee@kampong.social` as an `ATTENDEE` and the fixture owned by `caldav-test@kampong.social`, **an iMIP is expected**. Its arrival in the attendee's mailbox is a **pass**. Its absence is a **failure condition** — it would mean the mechanism is not what the source says, and every conclusion drawn from that source about suppression would be unreliable. The message never leaves the server: it enters the ordinary SMTP queue and the live route sends `is_local_domain(rcpt_domain)` to `local` before any `mx` fallthrough. **It does change the code**, at `1e88356`. The refusal message said Stalwart decides which attendees are actually mailed and that one hosted on it receives no message. It now says to assume every named attendee is written to, because the only address suppressed is the caller's own. Same conservative behaviour, reached by a correct mechanism rather than one that understated the risk by exactly the population most likely to be in a private calendar — colleagues on the same server. ## The general shape, since it nearly cost a run The belief was wrong and its conclusion — name every attendee, refuse by default — was right anyway. That is the kind of correction that gets skipped as academic. It was not academic, because **someone was about to observe something because of it**: an experiment built on it expects no mail for a same-server attendee, so a correct run would have looked like a bug and the finding would have been "Stalwart's suppression does not work" rather than "my model of it was wrong". When correcting a mechanism whose conclusion holds anyway, the question is who is about to make an observation that depends on it.
Author
Owner

Correction: my reading of the 401 was wrong, and no re-run could have caught it

I have been recording the blocker as "Stalwart's directory is OIDC, so no password authenticates for anybody". That is not what the 401 says, and it is not true.

The same 401 is returned by "this directory cannot hold passwords" and by "this principal has none". Nothing in the response separates them, so re-measuring it — which I did, more than once, and reported as diligence — could never have distinguished the two. A negative I re-ran carefully is still a negative from an instrument that has only one answer.

The discriminating measurement was a different population, and Clark ran it:

principals total          26
with a credential          6
with none                 20

Six principals carry a password, so password authentication works in that directory. My hypothesis cannot survive that number.

And the precedent was already there. scratch-spf-probe and sms-ingest are non-human principals with passwords, and caldav_test and caldav_test_attendee exist as principals with credEntries=0. The fixtures were never missing accounts. They were missing one field, and the established shape for a non-human principal on that directory was one query away.

How I got there, since the mechanism is the reusable part

Clark gave me the OIDC reading with the inference marked: "Read that last sentence as the inference it is: the error names the credential type and the dispatch file, so I am reasoning from the message rather than from the source." I adopted it as fact, repeated it in this issue, in #19's body and in three messages, and the marking did not survive the first retelling.

That is worse than making the inference myself. A claim that arrives flagged and leaves unflagged has been laundered by the retelling, and every later reader sees a measurement.

What is actually blocking, stated so it can be checked

A Stalwart-local credential on caldav_test, which Clark has dispatched. Both fixtures were checked against the live outbound routing table rather than against their names: neither appears in it, both have aliases=0 and groups=0, so a credential on them cannot affect anyone's mail.

And the next result is pre-registered here rather than after the fact: if the PROPFIND still 401s with a credential set, that is a real directory finding and it goes to Clark as one. It is not a cue to build an alternative acceptance, and I will not.

The experiment design above is unchanged.

## Correction: my reading of the 401 was wrong, and no re-run could have caught it I have been recording the blocker as *"Stalwart's directory is OIDC, so no password authenticates for anybody"*. **That is not what the 401 says, and it is not true.** **The same 401 is returned by "this directory cannot hold passwords" and by "this principal has none".** Nothing in the response separates them, so re-measuring it — which I did, more than once, and reported as diligence — could never have distinguished the two. **A negative I re-ran carefully is still a negative from an instrument that has only one answer.** **The discriminating measurement was a different population**, and Clark ran it: ```text principals total 26 with a credential 6 with none 20 ``` Six principals carry a password, so password authentication works in that directory. My hypothesis cannot survive that number. **And the precedent was already there.** `scratch-spf-probe` and `sms-ingest` are non-human principals with passwords, and `caldav_test` and `caldav_test_attendee` exist as principals with `credEntries=0`. **The fixtures were never missing accounts. They were missing one field**, and the established shape for a non-human principal on that directory was one query away. ## How I got there, since the mechanism is the reusable part Clark gave me the OIDC reading with the inference marked: *"Read that last sentence as the inference it is: the error names the credential type and the dispatch file, so I am reasoning from the message rather than from the source."* **I adopted it as fact, repeated it in this issue, in `#19`'s body and in three messages, and the marking did not survive the first retelling.** That is worse than making the inference myself. A claim that arrives flagged and leaves unflagged has been laundered by the retelling, and every later reader sees a measurement. ## What is actually blocking, stated so it can be checked **A Stalwart-local credential on `caldav_test`**, which Clark has dispatched. Both fixtures were checked against the live outbound routing table rather than against their names: neither appears in it, both have `aliases=0` and `groups=0`, so a credential on them cannot affect anyone's mail. **And the next result is pre-registered here rather than after the fact**: if the `PROPFIND` still 401s *with* a credential set, that is a real directory finding and it goes to Clark as one. It is not a cue to build an alternative acceptance, and I will not. The experiment design above is unchanged.
Author
Owner

Ran. Both guards I put on trial come out.

Executed against caldav_test@kampong.social on 2026-09-02, in a collection created for the purpose and deleted afterwards. Containment held as committed: MKCALENDAR at /dav/cal/caldav_test%40kampong.social/exdate-probe/, href read back and asserted before the first write, every write beneath it, DELETE returning 204 and the calendar home back to one collection. No fixture carried ATTENDEE or ORGANIZER, so no outcome could generate mail.

Credential verified with a control before anything else: PROPFIND /dav/cal/ returns 207 with it and 401 with a deliberately wrong password.

Step 0 answered more than expected, and inverted the design

Stalwart's expansion emits RECURRENCE-ID as a UTC instant in every case:

DTSTART;TZID=Asia/Singapore:20260901T090000  ->  RECURRENCE-ID:20260901T010000Z
DTSTART:20260901T090000 (floating)           ->  RECURRENCE-ID:20260901T090000Z
DTSTART;VALUE=DATE:20260901                  ->  RECURRENCE-ID:20260901T000000Z

The pre-registration said the positive control uses whatever form Step 0 observes, and that C1 and W1 swap roles if it emits UTC. Fixing the rule rather than the literal is what made the run interpretable: a control fixed to the zoned literal would have been the case I had labelled deliberately wrong.

Results, per instance rather than per count

Baselines: zoned series 6 instances with 8 Sep at 20260908T010000Z; all-day series 6 with 3 Sep.

case EXDATE written excluded
tzNone none baseline, 6
tzA EXDATE:20260908T010000Z yes
tzB EXDATE;TZID=Asia/Singapore:20260908T090000 yes
tzC EXDATE;TZID=UTC:20260908T090000 no
tzD EXDATE;TZID=Asia/Singapore:20260909T090000 no
dtNone none baseline, 6
dtA EXDATE;VALUE=DATE:20260903 yes
dtB EXDATE:20260903T000000 yes
dtC EXDATE:20260903T000000Z yes
f1 EXDATE;TZID=Asia/Singapore:20260901T090000 on the first occurrence yes

Every PUT returned 201 regardless of outcome.

Stalwart matches an EXDATE by instant, not by literal form. Zoned, UTC, floating and VALUE=DATE all exclude when they denote the same moment. tzC fails because 09:00 UTC is a different instant from 09:00 SGT, not because the label differs.

The pre-registered consequences, fired

W1 excluded anyway → the value-type refusal comes out. dtB and dtC exclude an all-day occurrence with a date-time, which is exactly the case that refusal exists to prevent. It was superstition on this server.

F1 honoured → the first-occurrence refusal comes out. f1 removed DTSTART's own occurrence, leaving five starting 8 Sep. "Some servers treat DTSTART as implicitly included" is not true of this one.

Both removals were written down before the run, and both cite this comment.

What survives, and it is the part the tool actually depends on: taking the EXDATE's shape from DTSTART is still correct, because a shape derived from the series is guaranteed to denote a generated instant. It is now a simplification rather than a guard.

W4 fired too, and it moves the roadmap

tzC and tzD are indistinguishable: both accepted with 201, both exclude nothing. So there is no server-side signal that an exclusion was pointless, and a caller cannot discover its own mistake from any response.

As pre-registered, that promotes the RRULE expander from "needed for delete_occurrences_in_range" to the only possible pre-flight check, ahead of truncate_series and the attended-series work.

Unchanged

The hazard itself is real and this run confirms it: a wrong instant is accepted and silently does nothing. Only its dependence on form was wrong.

## Ran. Both guards I put on trial come out. Executed against `caldav_test@kampong.social` on 2026-09-02, in a collection created for the purpose and deleted afterwards. Containment held as committed: `MKCALENDAR` at `/dav/cal/caldav_test%40kampong.social/exdate-probe/`, href read back and asserted before the first write, every write beneath it, `DELETE` returning 204 and the calendar home back to one collection. No fixture carried `ATTENDEE` or `ORGANIZER`, so no outcome could generate mail. Credential verified with a control before anything else: `PROPFIND /dav/cal/` returns **207** with it and **401** with a deliberately wrong password. ## Step 0 answered more than expected, and inverted the design **Stalwart's expansion emits `RECURRENCE-ID` as a UTC instant in every case:** ```text DTSTART;TZID=Asia/Singapore:20260901T090000 -> RECURRENCE-ID:20260901T010000Z DTSTART:20260901T090000 (floating) -> RECURRENCE-ID:20260901T090000Z DTSTART;VALUE=DATE:20260901 -> RECURRENCE-ID:20260901T000000Z ``` The pre-registration said the positive control uses whatever form Step 0 observes, and that C1 and W1 swap roles if it emits UTC. **Fixing the rule rather than the literal is what made the run interpretable**: a control fixed to the zoned literal would have been the case I had labelled deliberately wrong. ## Results, per instance rather than per count Baselines: zoned series 6 instances with 8 Sep at `20260908T010000Z`; all-day series 6 with 3 Sep. | case | `EXDATE` written | excluded | |---|---|---| | `tzNone` | none | baseline, 6 | | `tzA` | `EXDATE:20260908T010000Z` | **yes** | | `tzB` | `EXDATE;TZID=Asia/Singapore:20260908T090000` | **yes** | | `tzC` | `EXDATE;TZID=UTC:20260908T090000` | no | | `tzD` | `EXDATE;TZID=Asia/Singapore:20260909T090000` | no | | `dtNone` | none | baseline, 6 | | `dtA` | `EXDATE;VALUE=DATE:20260903` | **yes** | | `dtB` | `EXDATE:20260903T000000` | **yes** | | `dtC` | `EXDATE:20260903T000000Z` | **yes** | | `f1` | `EXDATE;TZID=Asia/Singapore:20260901T090000` on the **first** occurrence | **yes** | Every `PUT` returned 201 regardless of outcome. **Stalwart matches an `EXDATE` by instant, not by literal form.** Zoned, UTC, floating and `VALUE=DATE` all exclude when they denote the same moment. `tzC` fails because 09:00 UTC is a different instant from 09:00 SGT, not because the label differs. ## The pre-registered consequences, fired **W1 excluded anyway → the value-type refusal comes out.** `dtB` and `dtC` exclude an all-day occurrence with a date-time, which is exactly the case that refusal exists to prevent. It was superstition on this server. **F1 honoured → the first-occurrence refusal comes out.** `f1` removed `DTSTART`'s own occurrence, leaving five starting 8 Sep. "Some servers treat `DTSTART` as implicitly included" is not true of this one. Both removals were written down before the run, and both cite this comment. **What survives, and it is the part the tool actually depends on**: taking the `EXDATE`'s shape from `DTSTART` is still correct, because a shape derived from the series is guaranteed to denote a generated instant. It is now a simplification rather than a guard. ## W4 fired too, and it moves the roadmap `tzC` and `tzD` are **indistinguishable**: both accepted with 201, both exclude nothing. So there is no server-side signal that an exclusion was pointless, and a caller cannot discover its own mistake from any response. As pre-registered, that promotes the `RRULE` expander from "needed for `delete_occurrences_in_range`" to **the only possible pre-flight check**, ahead of `truncate_series` and the attended-series work. ## Unchanged The hazard itself is real and this run confirms it: a wrong instant is accepted and silently does nothing. Only its dependence on *form* was wrong.
Sign in to join this conversation.
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#16
No description provided.