• v0.9.0 562f66eed3

    v0.9.0
    All checks were successful
    CI / gate (push) Successful in 1m18s
    CI / image (push) Successful in 1m21s
    Stable

    joeyr released this 2026-08-09 22:10:26 -05:00 | 91 commits to main since this release

    Repeating events. Creating them, and changing or deleting this occurrence, this and
    every later one
    , or all of them — including the this-and-following split that had
    been held back for 0.9.1.

    Added

    • create_event can repeat, from a constrained vocabulary rather than a raw RRULE:
      repeat (daily, weekly, monthly, yearly), repeat_interval, repeat_days for a weekly
      rule, and either repeat_count or repeat_until. Give neither and it repeats
      indefinitely, which is the right answer for a standing weekly meeting.

      Not a raw rule string, deliberately. FREQ=SECONDLY with no COUNT is legal
      iCalendar, expands to millions of occurrences, and turns every later read of that
      calendar into a timeout. A vocabulary that cannot express it beats a validator that has
      to catch it.

    • scope on update_event and delete_eventthis, following, or all.
      Required for a repeating event, with no default. "Cancel Tuesday's standup" means
      one occurrence to a person; under a default of all it ends the standup permanently.
      There is no safe default, only a convenient one, so a missing scope is refused with the
      three choices named and scope_required: true in the envelope. A scope on a one-off
      event is accepted and ignored — it has one occurrence, so every scope names the same
      write.

    • Every occurrence of a series now carries an event_id. Before this, repeating
      events had none, because every write against them was refused. The handle gained a
      fourth field naming which occurrence it is — the value a RECURRENCE-ID carries, in
      UTC so it survives the server's TZ changing between being issued and being used.
      Shorter handles still decode, so a one-off event's handle is byte-identical to the one
      0.8.1 issued.

      What the absence of a handle means has narrowed accordingly: a subscription, or a
      calendar that is readable but not writable.

    The three scopes are three different operations

    Not three flags on one operation. Removing one occurrence of a series is a write, not a
    deletion
    — and that is the whole safety argument here:

    Scope Update Delete
    this add a RECURRENCE-ID override to the same resource add an EXDATE to the master
    following truncate the original with UNTIL, create a new resource for the remainder set UNTIL — one write, no second resource
    all edit the master DELETE the resource

    Only all, and following from the very first occurrence, are a DELETE. An
    implementation that reaches for obj.delete() because the caller said "delete" takes the
    entire series and gets exactly the same 204 back as taking one occurrence, so nothing
    downstream can tell.

    Two more consequences worth stating:

    • An override is built by copying the master, properties and VALARM reminders and all,
      never composed from the fields this server models. An occurrence becoming an override is
      the one moment a reminder could silently disappear, and a lost reminder is invisible in
      every response until an appointment does not ring.
    • following from the first occurrence, and truncation generally, degenerate. An
      UNTIL earlier than DTSTART writes a rule that generates nothing — a resource that
      exists and renders as no events at all. That case is detected and treated as the whole
      series instead.

    The split creates before it truncates

    Editing following is the only operation here that writes two resources, and CalDAV
    offers no transaction across them. One of the two writes can therefore succeed alone, and
    the order decides which failure the user gets:

    • remainder first, then truncate — a failure between them leaves a visible duplicate
      series from the split date onward, recoverable by hand;
    • truncate first — a failure leaves every future occurrence silently gone, behind a
      plausible-looking success.

    So the remainder is created first, and if the truncation then fails the error says
    precisely that both are on the calendar and nothing was lost, rather than "nothing was
    modified", which would be a lie.

    A series bounded by COUNT refuses a following edit. Splitting it correctly means
    knowing how many occurrences fall before the split, which needs the series expanded — and
    a count wrong by one is a missing or extra occurrence months away with nothing to indicate
    it. Deleting following on a counted series is fine: every occurrence being kept was
    inside the original count either way.

    Changed

    • recurring is read from the resource, not from the component. A RECURRENCE-ID
      override carries no RRULE, so asking the expanded copy reported a moved occurrence of
      a weekly meeting as a one-off. All of a series' components live in one resource (RFC
      4791 §4.1), so asking the resource cannot get it wrong. The flag is now correct for an
      override rather than merely not trusted.

    • The write instructions and both tool descriptions now carry the scope and its lack of a
      default. A model told only that series are "supported" picks a scope itself.

    Testing

    417 tests, and a twelve-mutation pass over the safety properties — each one
    individually reverted, each one caught. The three that were not caught on the first run
    became tests before this shipped, and all three were real gaps: a reminder surviving an
    occurrence becoming an override, an override not inheriting the series rule, and a second
    edit of the same occurrence editing that override rather than adding another.

    Recurrence has not run against the live account. docs/plan.md carries the eight
    checks, which want a scratch calendar — one of them deliberately tries to take a whole
    series.

    Downloads
  • v0.8.3 50fea2bb70

    v0.8.3
    All checks were successful
    CI / gate (push) Successful in 1m2s
    CI / image (push) Successful in 51s
    Stable

    joeyr released this 2026-08-09 20:53:43 -05:00 | 97 commits to main since this release

    create_event and list_events described the same all-day event two different
    ways. Found by running A9 against the live account.

    Fixed

    • An all-day end is reported the same way by every tool. The same event,
      in two responses:

      create_event →  end: 2026-08-10T23:59:59.999999-05:00   (inclusive last day)
      list_events  →  end: 2026-08-11T00:00:00-05:00          (exclusive boundary)
      

      build_vevent normalises an all-day end to the exclusive next-day DATE, but
      create_event built its reply from the arguments it was handed rather than
      from the document it had just written, so it echoed the input convention while
      every read reported the stored one. Nothing in either response said which was
      in hand.

      The round trip is what made it a footgun rather than a cosmetic difference:
      take an end from a response, shorten it to a date, hand it back as the last
      day, and the event grows by one — on every edit, silently.

      create_event now parses back the document it wrote, through the same
      parse_single that reads. Not a patch to the arithmetic: one code path, so
      the two cannot drift apart again. Responses are exclusive everywhere, and
      handing a reported end straight back is now exact.

    Changed

    • create_event's description says a timed event must carry a length. end
      and duration_minutes are both optional in the schema — because which one
      you send is a choice — but omitting both is an error, and nothing said so up
      front. It now does, and tells the model to ask rather than invent a duration.

    • It also states that responses report end exclusively, and to pass the
      value back verbatim rather than shortening it to a date.

    Testing

    The fake now makes a created event readable afterwards, which is what lets a
    test compare what create said against what a later read reports. That
    comparison was impossible to express before, which is why three releases shipped
    with the two disagreeing. Reverting the fix fails three tests.

    Downloads
  • v0.8.2 b1bf05633d

    v0.8.2
    All checks were successful
    CI / gate (push) Successful in 1m9s
    CI / image (push) Successful in 53s
    Stable

    joeyr released this 2026-08-09 20:34:33 -05:00 | 98 commits to main since this release

    list_events could return fewer events than the day held, with no error.

    Fixed

    • Expansion happens per resource instead of per calendar. Every resource on
      a calendar was merged into a single VCALENDAR before expanding, and two
      resources whose events share a UID collapsed into one occurrence:

      3 resources -> 3 events   [three distinct uids]
      3 resources -> 1 events   [same uid, different times]
      3 resources -> 2 events   [two share a uid]
      

      RFC 4791 §4.1 requires a calendar object resource to hold exactly one UID, so
      the resource is the correct unit of expansion — merging across resources
      merged things the spec calls distinct. Duplicate UIDs across resources are
      themselves a violation, but they occur; caldav's own iCloud notes list
      duplicate_in_other_calendar_with_same_uid_breaks.

      Found because a delete made the same query in the same window return more
      events than it had a moment earlier — 3 resources reporting 1 event, then 2
      reporting 2. Nothing raised. This is the third defect of that shape in this
      project and the second to survive a green suite.

      The merge existed to cover servers that split a series' RECURRENCE-ID
      overrides across resources. No compliant server does and iCloud does not, but
      the trade is worth naming: on one that did, this would show the master's
      occurrence un-overridden and the override, as two events.

    • A resource that fails to expand now costs only itself. The expansion was
      wrapped once around the whole calendar and returned no events at all on
      failure, so one malformed series could empty a day. That is the isolation
      parse_components already applied to a resource that will not parse.

    Changed

    • tool/events logs both filters — the calendar list, and the query's
      length together with how many events it matched from. A short count has two
      very different explanations, and the line carrying only the window could not
      tell a search that correctly matched one event from a read that lost two.
      Diagnosing the defect above needed exactly that distinction and the line did
      not carry it.

      The query's length, never its text: it is user input and this line goes to
      Portainer.

    Docs

    • docs/runbook.md and docs/oauth-persistence.md are gone. Roughly a thousand
      lines of them were ✅ DONE phase narrative describing a deployment that now
      exists. What was still live — the outstanding live checks, the failure-mode
      table, adding a second tenant, backing out — moved to docs/plan.md,
      which also carries the 0.9.0 recurrence design and the decisions behind it.
    Downloads
  • v0.8.1 dca80226a4

    v0.8.1
    All checks were successful
    CI / gate (push) Successful in 1m3s
    CI / image (push) Successful in 51s
    Stable

    joeyr released this 2026-08-09 19:53:19 -05:00 | 99 commits to main since this release

    update_event and delete_event did not work against iCloud at all. Every call
    failed on the lookup, before touching anything.

    Fixed

    • An event is now fetched by its resource URL instead of searched for by UID.
      0.8.0 resolved a handle with caldav's get_event_by_uid, which issues a
      calendar-query REPORT filtering on UID. iCloud answers that with 412
      Precondition Failed
      — caldav's own source lists
      get_object_by_uid_is_broken among the iCloud quirks — so both write tools
      failed at _load_for_write on every call:

      ERR caldav/write > could not load event for writing | error=ReportError at '412 Precondition Failed'
      ERR tool/delete  > delete_event failed | error=CalendarUnavailable: Could not read that event back
      

      Nothing was ever modified, and the error envelopes reported that correctly —
      the failure was total rather than partial, which is the one thing that went
      right. A CalDAV resource is addressed by URL, and the href came back with the
      read that issued the handle in the first place; searching for it again was
      doing the work twice, through the one query shape this server cannot rely on.

    • event_id now carries the resource filename as a third field. The
      encoding is internal and no tool signature mentions its contents, so this is
      not a breaking change to the API — but handles issued by 0.8.0 name no
      resource and are refused with "list the events again" rather than resolved.
      Since no 0.8.0 handle could ever be written through, nothing that worked
      before stops working.

      The filename only — ABC-123.ics, never the path it hangs off, which is
      /<dsid>/calendars/<collection>/ and carries the account identifier. The URL
      is rebuilt server-side against the calendar the allowlist already resolved, so
      a handle still cannot point a write at a calendar it does not name.

    • An event whose resource filename is unknown gets no event_id, which
      extends the existing rule rather than adding one: a handle that cannot be
      resolved is worse than no handle, and absence already means "not editable" for
      repeating events, subscriptions and read-only calendars.

    • A resource that no longer holds the event it was read as is refused. An
      href can be reused after a delete, and editing whatever moved in — under the
      old event's title — is the one outcome worse than failing.

    Testing

    The fake CalDAV handle now raises 412 on get_event_by_uid the way iCloud
    does
    , so the 0.8.0 bug fails the suite instead of passing against a fake more
    capable than the real server. Reverting the fix fails 17 tests, six of them new,
    including a full read-to-write round trip — list_events, take the event_id
    off an event, delete it — which nothing covered before. That gap is exactly how
    a total outage of both write tools shipped green.

    Downloads
  • v0.8.0 811a992fa6

    v0.8.0 — update_event and delete_event, addressed by opaque handles
    All checks were successful
    CI / gate (push) Successful in 1m2s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-09 16:44:19 -05:00 | 100 commits to main since this release

    Editing and deleting, addressed by an opaque handle. 0.7.0 could put things on
    the calendar and never take them off again.

    Added

    • update_event — changes the fields it is given on one stored event and
      leaves the rest alone. It mutates the stored resource rather than rebuilding
      it
      , which is the difference between an edit and a quiet amputation: a
      rebuild from the handful of fields this server models would discard the VALARM
      reminders, attendees, categories and Apple X-properties it does not, and
      nothing in the response would say so.

      Moving an event preserves its length, so a start on its own is enough. Whether
      an event is all-day is read from the stored event, never from the shape of the
      argument — converting a day off into a 30-minute appointment because a caller
      passed a time is not a thing a caller should be able to do by accident.

    • delete_event, which requires expected_title and refuses on a mismatch.
      It costs one argument the caller already has, and it converts the failure that
      cannot be undone — two ids transposed out of one response — into a refusal
      that names both titles. The response carries the whole deleted event, since
      that is the only remaining record of what was there.

    • event_id, an opaque handle on every addressable event. Not the resource
      URL, which carries the account DSID and must never reach the model; not the
      uid, which every occurrence of a series shares; and not (calendar, uid) as
      two arguments, which lets a model pair the right uid with the wrong calendar
      or assemble one from parts it half-remembers. One string, produced by a read,
      passed back verbatim.

      Its absence is the signal. No event_id means nothing here can be edited
      or deleted: repeating events, subscriptions, and everything on a calendar that
      is readable but not writable. With WRITABLE_CALENDARS unset no event carries
      one at all, so the read surface still matches the read-only releases exactly.

      Deliberately unsigned. The write allowlist is checked against the calendar
      named inside the handle before iCloud is contacted, so forging one buys
      access to exactly the calendars the operator already opted in to. Signing
      would add a key with the MCP_OAUTH_SIGNING_KEY failure mode — rotate it and
      every handle issued earlier in a conversation stops resolving, which reads as
      the events having vanished.

    Changed

    • Every error envelope out of a write tool now states created, updated or
      deleted as false.
      They were present on the failures raised from the
      client and absent on the ones caught during argument validation. A missing key
      is not the same signal as a false one, and the gap was on exactly the paths
      where the model has the least other evidence.

    • The server instructions name the new capabilities and their limits together,
      because stating the capability alone invites the model to offer the rest.

    Security

    • Repeating events are refused, and the refusal is checked against the stored
      resource rather than against anything a read inferred.
      A RECURRENCE-ID
      override carries no RRULE of its own, so a moved instance of a weekly meeting
      reads as a one-off — and editing it by UID resolves to the resource holding
      the whole series. Deleting is the same story with a worse ending: one DELETE
      against a series master removes every occurrence, past and future, and returns
      the same 204 as removing a single event.

    • A concurrent edit is refused, not overwritten. caldav sends If-Match
      from the ETag of an object it loaded, so an event changed on a phone between
      the read and the write comes back 412 and is left alone.

    • Upgrading grants deletion to anyone who had WRITABLE_CALENDARS set for
      0.7.0
      , with no configuration change. This is the roadmap's shared gate
      working as agreed, and it is worth saying out loud: the opt-in that previously
      meant "may add events" now also means "may remove them". Clearing the variable
      is still the off switch.

    Downloads
  • v0.7.0 ab790e5c9f

    v0.7.0 — create_event, behind a per-calendar write allowlist
    All checks were successful
    CI / gate (push) Successful in 1m0s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-09 15:27:21 -05:00 | 103 commits to main since this release

    First release that can change the calendar. Everything before this was read-only by
    construction; from here it is read-only by configuration, and the configuration
    defaults to read-only.

    Added

    • create_event — creates one non-recurring event on a calendar named in the new
      WRITABLE_CALENDARS allowlist. Accepts a finish time as either end or
      duration_minutes, handles all-day events, and echoes the whole created event back
      rather than an acknowledgement, so a timezone or all-day misunderstanding shows up in
      the transcript instead of on someone's phone a day later.

    • WRITABLE_CALENDARS, empty by default — and empty means the tool is not
      registered
      , not that it refuses. An offered tool is one the model will promise the
      user before it discovers it cannot deliver, so with this unset the served tool surface
      is identical to 0.6.0.

      Separate from CALENDARS rather than a flag on it: reading a shared household
      calendar to answer "when is everyone free" is not consent to write to it.

    • A duplicate guard. A create whose response the caller never saw — a timeout on the
      write — leaves the event created and the model convinced it was not, and the retry
      books the meeting twice. An event with the same title and start is refused, and the
      refusal carries the existing event so the user can be told what is already booked.
      allow_duplicate=true overrides it. Best-effort by design: if the check cannot read
      the calendar the create still proceeds, because turning iCloud throttling into an
      inability to add anything is a worse failure than the one being prevented.

    • A writable preflight check and writable_calendars on the boot line. A name in
      the allowlist matching no readable calendar registers a tool that refuses every call,
      which is invisible otherwise. Reported as a failure but never fatal — a typo in the
      write allowlist must not take the read path down with it.

    • mcp_gateway/calendar/compose.py, the inverse of expand.py and network-free for
      the same reason. Two things in it are the ones worth getting right: DTEND is
      exclusive, so a one-day all-day event ends on the following date — writing the same
      date twice yields a zero-length event most clients render as nothing at all. And timed
      events are written in UTC rather than with a TZID, because a TZID reference is
      only legal alongside a matching VTIMEZONE component; these are single non-recurring
      instances, so there is no future DST transition for a zone rule to get right.

    Changed

    • Calendar.read_only reports the truth instead of a hardcoded true. It is how
      the model knows which calendar to offer. Subscriptions are always read-only — a feed is
      an HTTPS GET against someone else's server, and naming one in WRITABLE_CALENDARS
      does not change that.

    • The server instructions name the writable calendars and, just as explicitly, the
      limits: nothing can be edited or deleted, and repeating events cannot be created.
      Stating the capability without the limits invites the model to offer the rest.

    Security

    • The credential was never the boundary and still is not. An app-specific password
      has granted full CalDAV read and write since the first release; what changed is that
      this code will now use it. WRITABLE_CALENDARS is the boundary, it is enforced before
      iCloud is contacted, and it is checked before the calendar name is even looked up — so
      a real calendar that is merely not writable does not come back as "no such calendar".

    • Resource URLs are never logged or returned. They carry the account DSID, and tool
      output reaches the model.

    Downloads
  • v0.6.0 5c325e3132

    v0.6.0 — OAuth state survives a redeploy
    All checks were successful
    CI / gate (push) Successful in 1m8s
    CI / image (push) Successful in 52s
    Stable

    joeyr released this 2026-08-09 13:35:06 -05:00 | 104 commits to main since this release

    Added

    • OAuth state now persists across redeploys. The connector was signed out by every
      single stack update. FASTMCP_HOME is set explicitly to /var/lib/fastmcp — in the
      image and in the stack — and the stack mounts an NFS volume there, so the encrypted
      file store OAuthProxy was already writing survives a service update.

      Set explicitly rather than left to platformdirs, because the default is derived from
      the container user and $HOME: a base-image change would relocate the store silently,
      and a moved store is indistinguishable from a lost one. The volume is inline rather
      than external: true for the reason named_volumes_guide.md gives — Portainer here
      cannot target a node and there is no SSH to the Pis, so an external volume exists on
      exactly one node, and a task landing anywhere else gets an empty one. That would bring
      the sign-out back intermittently, which is worse than constantly. It sits on
      volume1 (NVMe) rather than volume2 (HDD): a tree of tiny files rewritten on every
      token refresh is exactly the small-random-write case the guide reserves the NVMe for.

      This does not raise the replica ceiling. A file store is per-container-filesystem,
      so replicas: 1 still stands under AUTH_MODE=oauth. See docs/oauth-persistence.md.

    • An oauth_storage preflight check, and fastmcp_home on the boot line.
      OAuthProxy mkdirs its storage in its constructor, so an unwritable path was a
      PermissionError during app construction — a crash loop whose traceback named
      fastmcp, pathlib and a hashed directory, and mentioned neither the volume nor its
      owner. That is the likely first failure here: the local NFS driver never contacts the
      server until a container mounts it, so a missing export directory or wrong ownership
      produces a perfectly healthy-looking volume and fails only at start. The check runs
      before the app is built and names it in one line. An unset FASTMCP_HOME is advisory,
      not a failure — it is legal, just drift waiting to happen.

    Downloads
  • v0.5.1 7f8a611f5e

    v0.5.1 — CalDAV stays on TCP; CI stops cancelling publishes
    All checks were successful
    CI / gate (push) Successful in 1m1s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-09 11:08:34 -05:00 | 105 commits to main since this release

    Changed

    • CalDAV now stays on TCP; HTTP/3 is disabled for the iCloud session. iCloud answers
      with Alt-Svc: h3=":443", and caldav's HTTP layer acts on it: measured against the live
      host, request 1 negotiates HTTP/1.1 and every request after it negotiates HTTP/3 over
      UDP 443. That is fine on a network carrying UDP 443 cleanly and invisible on one that
      is not — a blocked or MTU-broken UDP path returns no error, only a request that never
      comes back. HTTP/3 buys a handful of small CalDAV requests nothing, so the dependency
      is removed rather than diagnosed.

      Framed honestly: this is hardening, not a fix for a reproduced bug. The timeouts
      seen while investigating stopped without it.

    • CI no longer cancels a superseded run. cancel-in-progress was cancelling
      publishes, not just tests: two pushes a minute apart killed the first one's image
      job partway through, so that commit's :<sha> tag — the only immutable rollback target
      CI produces — never reached the registry, and the window where :latest still pointed
      at the previous version got wider. Superseded runs now queue. The cost is runner time
      re-proving a commit nobody will deploy; the alternative loses artifacts that cannot be
      recreated without pushing an empty commit.

    Fixed

    • The explanation for signing in again after a redeploy was wrong, in the runbook, in
      server.py and in the stack file. OAuthProxy built without a client_storage does
      not fall back to memory — it builds an encrypted file store under
      $FASTMCP_HOME/oauth-proxy/<key-fingerprint>/. The state is written to disk and would
      survive a restart; it is lost because that path sits in the container's writable layer
      with nothing mounted there. The fix is therefore a volume, not a database, which is a
      much smaller change than the one previously written down. docs/oauth-persistence.md
      carries the plan, including why it still does not unblock replicas > 1.
    Downloads
  • v0.5.0 4522567669

    v0.5.0 — Concurrent calendar search and retried timeouts
    Some checks failed
    CI / gate (push) Successful in 1m1s
    CI / image (push) Failing after 1s
    Stable

    joeyr released this 2026-08-09 09:53:14 -05:00 | 109 commits to main since this release

    Changed

    • Calendars are now searched concurrently rather than one after another. Serially, a
      slow calendar added its latency to every calendar behind it: on 2026-08-09 iCloud
      stalled three of four calendars at the 30-second read timeout, so a single tool call
      spent 90 seconds before returning a partial answer — well past the point the caller
      gives up, and indistinguishable from a hang. Concurrently the worst case is the slowest
      single calendar. Capped at 4 in flight, deliberately: enough parallelism to trip
      iCloud's throttling would trade one slow calendar for four rate-limited ones.

    • A timed-out calendar is retried once before being reported unavailable. A read
      timeout against iCloud is usually throttling rather than an outage, and it previously
      cost the entire calendar for the length of the cache TTL. Only transport-level failures
      are retried — retrying a 403 or a malformed response just doubles the latency of a
      failure that was never going to succeed. caldav's own 429/503 handling is enabled at
      the same time, capped at 10 seconds so a server-supplied Retry-After of an hour
      cannot hold a tool call open for one.

    • The CalDAV timeout is now set explicitly and configurable (CALDAV_TIMEOUT,
      default 60s). It was previously whatever niquests defaults to — 30 seconds, a number
      that first appeared in a production stack trace and had to be traced back to a library
      to find out whether anyone had chosen it. It appears on the boot line.

    Added

    • A log line before the network work starts, naming the window and how many calendars
      are being read. The existing listed events line carries the window too, but it is
      only reached once every calendar has answered — so for exactly the requests that hang,
      the one field that explains why was the one never written.

    Fixed

    • Warnings rendered uncoloured and unabbreviated in Portainer. The viewer matches
      debug/info/warn/error case-sensitively, and the formatter emitted
      record.levelname.lower() — which is warning for Python's WARNING, one letter off
      the token. debug, info and error agree by coincidence, which is what hid it: the
      one level whose entire purpose is to be noticed was the only one rendering plain. That
      covered every warning this service has emitted, including the two CALENDARS allowlist
      warnings that exist specifically to be seen. Now an explicit mapping, with critical
      folded into error because the viewer knows five tokens and that is not one.
    Downloads
  • v0.4.0 c0b07c397f

    v0.4.0 — Read-only iCalendar subscriptions
    All checks were successful
    CI / gate (push) Successful in 1m1s
    CI / image (push) Successful in 51s
    Stable

    joeyr released this 2026-08-09 09:53:14 -05:00 | 110 commits to main since this release

    Added

    • Read-only iCalendar subscriptions (ICS_FEEDS), fetched over HTTPS and merged into
      the existing tools. A subscribed calendar is not stored in the iCloud account — every
      device polls the URL independently — so CalDAV never returns one and no CALENDARS
      value could ever reach it. That is why Apple's US Holidays was missing rather than
      filtered: the account only ever offered six collections and it was not among them.
      Feeds go through the same expand() as CalDAV data, so an occurrence from a
      subscription is indistinguishable downstream, including recurrence and all-day
      handling. Verified against Apple's real US_en.ics: 45 events in 2026, July 4 spanning
      exactly one day.

      Fetching the feed rather than importing it into a real calendar is the point — the file
      currently runs to Dec 2029, and an import would quietly stop returning holidays when it
      ran out, with nothing to say why.

      Failure modes here nearly all produce an empty calendar rather than an error, so each
      is made loud: an undecoded gzip body (iCloud serves gzip whether asked or not), an HTML
      error page served as 200, a body over 5 MB, a non-http(s) scheme (urlopen will read
      file:// quite happily), and a feed that is simply unreachable — which reports the
      calendar as unavailable rather than empty, and logs an advisory at boot.

      Not filtered by CALENDARS: configuring a feed is already the act of opting in, and the
      name is not knowable until after the first fetch.

    • A subscriptions preflight check, present only when feeds are configured. Advisory
      rather than fatal — a third-party URL being down is no reason to stop serving the
      account's own calendars.

    • The boot log now names the calendars the allowlist hid, not just how many.
      skipped_by_allowlist=2 answers "am I seeing everything?" with "no" and stops there,
      and the only other way to learn the names was to run once with CALENDARS empty —
      deliberately reading every subscribed collection on the account to find out which ones
      to exclude. A calendar created on the phone now shows up in this line.

    • A CALENDARS entry that matches nothing is now warned about individually. The
      existing warning only fired when every entry was wrong; one bad entry among several
      good ones started cleanly and quietly never consulted that calendar, which is
      indistinguishable from an empty calendar at the tool layer. The realistic cause is
      emoji — a warning sign is two codepoints (U+26A0 U+FE0F) and the bare U+26A0 renders
      identically without matching.

    Fixed

    • CI's gate job ran on Python 3.11 while the container runs 3.12, so nothing
      exercised the interpreter production actually uses. It runs on 3.12 now, and
      requires-python and mypy's python_version moved to 3.12 to match — a floor nothing
      tests is a claim nobody checks, and one version everywhere is simpler than two. The
      package is not published anywhere, so nothing downstream cares about the floor.
      Dropping the 3.11 leg also halves CI wall-clock: there is one shared runner, so matrix
      legs ran sequentially rather than in parallel.
    Downloads