Skip to content

feat!: parse create like update, give every refusal one error shape, and narrow the library API - #26

Merged
k3KAW8Pnf7mkmdSMPHz27 merged 14 commits into
mainfrom
feat/v3-one-parse-path
Oct 8, 2026
Merged

k3KAW8Pnf7mkmdSMPHz27 merged 14 commits into
mainfrom
feat/v3-one-parse-path

Conversation

@k3KAW8Pnf7mkmdSMPHz27

@k3KAW8Pnf7mkmdSMPHz27 k3KAW8Pnf7mkmdSMPHz27 commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

The deliberate major release of the refactoring series. Merging this cuts v3.0.0 through the ! in the title. Every change a client or a library consumer can observe is listed here. Review it commit by commit.

Observable changes for MCP clients

Create now reads input exactly like update

  1. A wrong-typed notes, dueDate or startDate on create fails that item. Before, create dropped the field silently. The advertised schema already forbade these values.
  2. Create reads dueTimeZone and startTimeZone only together with their date, as update always has. Before, create rejected an invalid zone sent without its date. It now ignores that zone.
  3. done on create is honoured. The schema advertised it, but create ignored it.
  4. A wall-clock absoluteDate is read in startTimeZone, else dueTimeZone. Before, it used the server's zone. The zone is resolved only for an absolute alarm, so a stray zone beside other alarm kinds is not checked.

Fields now match what Reminders stores (found by the smoke test against a real database)

  1. Relative alarms count back from the due date, and need one. Clearing the due date while relative alarms stay is refused too. Reminders.app counts them from the due date: a 30-minute alarm on a reminder due 10:00 and starting 08:00 shows at 9:30. Before, the server required a start date instead, and said so in its descriptions. The refusal now reads "Relative alarms require a due date". Before, create said "Invalid alarms: relative alarms require startDate" and update said "Relative alarms require a start date". The text output says "15 min before due" and "at due time".
  2. The location text field is gone from the write input and the reminder output. Reminders never stored it: it read back empty after every save, in v2.0.1 too, and a location set in Reminders.app is stored only as a location alarm. The alarms description and the README now point to location alarms.
  3. List colours read back as sent. Colours were written as Generic RGB, so #FF5733 came back as #FF6F41, in v2.0.1 too.
  4. Cleared notes read back as absent. The store saves cleared notes as an empty string, which the output now maps to no notes.

Errors have one shape

  1. Every refusal starts with Error: . This now includes the read-only refusal, an unknown tool, and missing or invalid parameters. Before, eight early returns had no prefix.
  2. A non-string delete element fails only that item, reported as delete[i]. Before, it rejected the whole call. A non-object upsert element now reads "Invalid item format: expected an object" instead of "Invalid item format".
  3. A batch where every item fails starts with "No changes made." Before, it started with a blank line.
  4. A hidden default list no longer leaks its ID. Creating without listId, when the default list is outside --allowed-lists, fails with "The default reminder list is outside --allowed-lists; pass listId". Before, the message carried the default list's identifier, which the server had looked up.

Startup and data

  1. The server exits if it cannot check --allowed-lists. Before, a busy event store made the startup check pass as "unrestricted", and the server served anyway.
  2. Marking a done reminder done again keeps its completion date. EventKit restamps it on every isCompleted = true, so the server now writes the flag only when it changes.

Schemas

  1. Alarm kind, reminder priority and the list action in the output schemas now carry the enum their values already came from. The bytes on the wire are unchanged.
  2. notes, dueDate and url now say "Set to null to remove", like the other clearable fields. FailureOutput.id says what it holds, absoluteDate says which zone a time without an offset uses, and the relative-alarm descriptions name the due date.

This PR's diff of the golden contract, Tests/EventKitMCPTests/Contract/tool-contract.json, shows items 5, 6, 15 and 16 and nothing else.

Library API changes

swift package diagnose-api-breaking-changes v2.0.1 --products EventKitService reports exactly these:

  • enum ReminderPriority has removed conformance to CaseIterable
  • func ReminderService.validateAllowedLists() is now throwing
  • enumelement ReminderServiceError.defaultListNotAllowed has been added as a new enum case
  • enumelement ReminderServiceError.relativeAlarmRequiresDueDate has been added as a new enum case
  • typealias Reminder has been removed
  • typealias ReminderList has been removed
  • func ListAccessPolicy.filter(_:) has been removed
  • var AllowedListValidation.unrestricted has been removed
  • enum ReminderAlarmModel.Kind has been removed
  • var ReminderAlarmModel.kind has been removed
  • var ReminderListModel.reminderCount has been removed
  • constructor ReminderListModel.init(id:title:color:isSubscribed:isImmutable:sourceTitle:reminderCount:) has been removed
  • var ReminderModel.creationDate has been removed
  • var ReminderModel.lastModifiedDate has been removed
  • var ReminderModel.location has been removed
  • constructor ReminderModel.init(id:title:notes:done:priority:dueDate:dueTimeZone:isAllDay:doneDate:listId:listName:creationDate:lastModifiedDate:recurrenceRule:url:location:startDate:startTimeZone:isStartAllDay:alarms:) has been removed
  • typealias ReminderPriority.AllCases has been removed
  • var ReminderPriority.allCases has been removed
  • var CreateReminderRequest.location has been removed
  • constructor CreateReminderRequest.init(title:notes:listId:dueDate:dueTimeZone:isAllDay:priority:recurrenceRule:location:url:startDate:startTimeZone:isStartAllDay:alarms:) has been removed
  • var UpdateReminderRequest.location has been removed
  • constructor UpdateReminderRequest.init(id:title:notes:done:dueDate:priority:listId:recurrenceRule:location:url:startDate:alarms:) has been removed
  • func ReminderServiceProtocol.getList(id:) has been removed
  • func ReminderServiceProtocol.getReminder(id:) has been removed
  • func ReminderService.getList(id:) has been removed
  • func ReminderService.getReminder(id:) has been removed
  • enumelement ReminderServiceError.relativeAlarmRequiresStartDate has been removed

The location members and everything else removed were unused by the server, or never stored by Reminders. CreateReminderRequest.init gained a defaulted done: parameter, so labelled call sites still compile once location: is dropped. defaultListNotAllowed and relativeAlarmRequiresDueDate are new cases, so an exhaustive switch over ReminderServiceError needs updating.

Smoke test

docs/SMOKE_TEST.md ran on 2026-10-07 against the real Reminders database on macOS 26.6.2, in a scratch list that was deleted afterwards. The release build, the tool surface, the allowlist, the round trip, the zones, the completion date, relative alarms, the coloured list and the overview header all pass on the final build. Steps 2 and 11 need another account and a Claude Desktop restart, and were not run.

The run found the problems behind items 5 to 8 and 11, all of which shipped in v2.0.1. It also confirmed three EventKit behaviours, now in the README and the checklist:

  • A timed due date gets a matching start date.
  • A start date with no due date loses its time zone.
  • Marking a recurring reminder done splits off a done copy of the current occurrence and moves the reminder to its next one, as Reminders.app does.

Verification

  • swift test: 95 library tests and 114 server tests pass.
  • Both lints pass, and the SwiftLint ceilings drop to the new maxima.
  • First adversarial review. Four reviewers covered tool-layer correctness, the allowlist invariants, contract and documentation consistency, and test integrity. A skeptic then tried to refute each finding. Of 19 findings, 8 survived, reducing to five low-severity issues. All are fixed or listed above.
  • Second adversarial review, of the three commits the smoke test produced: two reviewers and their skeptics confirmed one medium issue. An update that cleared the due date but left alarms out kept its relative alarms. It is now refused before anything changes, and a test pins it.

Mutation record

Each mutation was reverted before committing.

Mutation Failing tests
Create reads notes with stringValue again Create fails an item whose field has the wrong type instead of dropping the field
Create drops a non-string startDate again Create fails an item whose field has the wrong type instead of dropping the field
Create sends done: false Create passes done and the parsed dates to the service
The service ignores done on create Create can start a reminder completed
Alarms prefer the due zone over the start zone Wall-clock absolute alarms anchor to the start zone, else the due zone
Resolve the alarm zone eagerly Wall-clock absolute alarms anchor to the start zone, else the due zone
Create validates dueTimeZone without its date Create reads a time-zone key only with its date, like update
Create checks relative alarms against the start date again Create refuses a relative alarm without a due date, even with a start date, and saves nothing
Update checks relative alarms against the start date again Update applies the due date before validating relative alarms
Let a due-date clear keep relative alarms Clearing the due date is refused while relative alarms stay, and allowed once they go
Write colours as Generic RGB again Colours are written as sRGB and read back as sRGB from any colour space; Hex colours round-trip and malformed ones are rejected
Read colours without converting to sRGB Colours are written as sRGB and read back as sRGB from any colour space
Map empty notes as they are Empty notes map to no notes
Leave the summary blank when every item fails A batch where every item fails says no changes were made
Drop the Error: prefix in the single catch Every refusal is an error result whose text starts with 'Error: '
Return the read-only refusal early again Every refusal is an error result whose text starts with 'Error: '
Drop the per-item failure for a non-string delete element A non-string delete element fails that item and the rest still run
Restore the old upsert-element message A non-object upsert element fails that item and names what was expected
Throw listAccessDenied with the default list's ID again Create refuses a default list outside the allowlist without naming it
Swap the low and medium output priorities Enumerated and patterned strings map exactly and reject everything else
Make notes non-nullable in the input schema Representative inputs validate against their advertised schemas; Tool contract matches the checked-in snapshot
Answer a validation timeout as unrestricted again Validation that cannot reach the event store throws instead of passing
Write isCompleted even when unchanged Marking a done reminder done again keeps its completion date

The location removal adds no guard, so it has no mutation. The golden diff and the API diff are its proof.

Release impact

Major: v3.0.0. The tap bump follows automatically.

https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu

Create builds its request from parseStringField, parseDateField,
parseURLField, parseRecurrenceField and parseAlarmsField, so a
wrong-typed notes, location, dueDate or url fails the item instead of
being dropped, and `done` on create is honoured. The tool-layer
relative-alarm pre-check goes; the service's check and message apply.
Wall-clock absoluteDate values anchor to startTimeZone, else
dueTimeZone, like every other date input. parseDate,
requireDateWithTimeInfo and parseURL are gone.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
Parameter errors join ParseError, the read-only refusal and the unknown-tool
error get a small ToolCallError, and the single catch in handleToolCall builds
every error result. Every refusal now reads "Error: ..."; before, eight early
returns carried no prefix.

A non-string element in write_reminders' delete array now fails that item as
delete[i], like a malformed upsert element, instead of rejecting the call.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
Creating a reminder without listId, when the default list is outside
--allowed-lists, threw listAccessDenied with the default list's identifier: an
ID the server looked up, not one the caller supplied. The new argument-free
defaultListNotAllowed says "The default reminder list is outside
--allowed-lists; pass listId". The README now states what the allowlist does
to callers.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
…null

The output schemas now carry the enums the values already came from:
alarm kind, reminder priority and the manage_reminder_list action. The bytes are
unchanged; ReminderPriorityInput(_:) replaces displayName.lowercased().

notes, dueDate, location and url now say "Set to null to remove", like the
other three clearable fields, and FailureOutput.id says what it holds. A test
pins that every clearable field's advertised schema accepts null.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
Removed from EventKitService, none of which the server called:
- the Reminder and ReminderList typealiases;
- ReminderListModel.reminderCount and ReminderModel.creationDate and
  lastModifiedDate, which nothing rendered;
- CaseIterable on ReminderPriority, and ListAccessPolicy.filter;
- getList(id:) and getReminder(id:), from the protocol and the actor.

validateAllowedLists() now throws. It used to answer a busy event store with
an unrestricted, non-fatal result, so startup logged a restriction to zero
lists and served anyway; now the server exits, as it does when access is
denied. AllowedListValidation.unrestricted existed only for that fallback.

A test holds an injected operation gate and expects the timeout.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
EventKit restamps completionDate whenever isCompleted is set to true, so
sending done: true for a reminder that was already done moved its completion
date to now. The update now writes isCompleted only when it changes. The
manual stamp for a missing completionDate is gone: EventKit always sets one.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
- An absolute alarm resolves the item's zone only when it needs one. A stray
  dueTimeZone beside a location or relative alarm no longer fails the update,
  as it did not before this branch. The absoluteDate description now says a
  time without an offset is read in startTimeZone, else dueTimeZone.
- ReminderAlarmModel.Kind and .kind lose their last use and are removed.
- Tests pin a non-string startDate on create, a zone key without its date on
  create, and the non-object upsert element's message. A stale comment about
  completion stamping now matches the fix.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
- List colours were written as Generic RGB, so the store shifted them:
  #FF5733 read back as #FF6F41, in v2.0.1 as well. Colours are now written as
  sRGB, and read back converted to sRGB and rounded.
- The store saves cleared notes as "", so notes cleared with null read back as
  an empty string. Empty notes now map to none.
- A write batch where every item failed began with a blank summary line. It
  now says "No changes made.", as an empty batch already did.
- The README says what Reminders does on save: a timed due date gets a
  matching start date, a start date without a due date loses its zone, and
  completing a recurring reminder splits off the done occurrence.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
… location

Reminders never stores the plain location text. The smoke test showed it read
back as nil after every save, through the server and through plain EventKit,
in v2.0.1 too. A location set in Reminders.app is saved purely as a location
alarm, whose title is the place. The write input, the reminder output and the
library's request and model types lose `location`. The alarms description and
the README now point to location alarms instead.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
The server required a start date for relative alarms and documented them as
counting back from it. Reminders.app counts them from the due date: a
30-minute alarm on a reminder due 10:00 and starting 08:00 shows at 9:30, and
one with only a start date shows no time at all. A relative alarm now needs a
due date. relativeAlarmRequiresStartDate becomes relativeAlarmRequiresDueDate,
"Relative alarms require a due date", and the text output says "15 min before
due" and "at due time".

The smoke checklist gains this step, splits the done and recurring round
trips, and records the 2026-10-07 run.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
The second adversarial review found that an update sending dueDate: null
without alarms kept the reminder's relative alarms, leaving the no-time state
that create now refuses. The update now throws relativeAlarmRequiresDueDate
before changing anything; clearing the due date together with the alarms, or
once only absolute and location alarms remain, still works. The contract
test's valid write example pairs its relative alarm with a due date.

Claude-Session: https://claude.ai/code/session_01XR9wyQPT1iM8wzP9hvmYRu
@k3KAW8Pnf7mkmdSMPHz27
k3KAW8Pnf7mkmdSMPHz27 merged commit d84e9da into main Oct 8, 2026
4 checks passed
@k3KAW8Pnf7mkmdSMPHz27
k3KAW8Pnf7mkmdSMPHz27 deleted the feat/v3-one-parse-path branch October 8, 2026 18:00
@eventkit-tap-bumper

Copy link
Copy Markdown

🎉 This PR is included in version 3.0.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant