Refuse occurrence URLs instead of acting on the whole series
Fixes #947.
What
The shared ID extractors in urlarg now refuse an occurrence URL (schedule_entries/{id}/occurrences/{date}, with or without /edit). They return an *urlarg.OccurrenceError instead of the entry's ID. The commands package turns it into a usage error:
That URL names one occurrence of a recurring event
Hint: To comment on, list the comments of, or subscribe to just this occurrence, pass its own ID, which basecamp show https://3.basecamp.com/99999/buckets/123/schedule_entries/789/occurrences/20261022 prints. To update, trash or boost the event, pass its ID, 789, which acts on the whole series. Basecamp's API cannot change a single occurrence
The extractors now return an error, so every caller has to take it. A codemod rewrote 73 of the 79 call sites in internal/commands. I edited the other 6, and one call through a function value, by hand. The compiler rejects a caller that does not take the error, so no call site can drop it without an explicit _. A comment link on an occurrence (…/occurrences/20261022#__recording_555) still names comment 555, in attachments list and download too.
Reads of an occurrence URL still work, and they read the occurrence:
showandschedule showread the URL withParse, not the extractors, and show the occurrence. They now read the occurrence's own comments, and their comment breadcrumbs name the occurrence's recording ID, not the series.schedule showdrops itsschedule updatebreadcrumb for an occurrence, since the API cannot update one.attachments listanddownloadread the occurrence throughGET /schedule_entries/{id}/occurrences/{date}. An occurrence has no description of its own; bc3 serves its event's, so these are the event's attachments. The comment breadcrumb names the occurrence's recording, and the show and download breadcrumbs name the URL. With a--typeother than a schedule entry or a comment, the URL is refused.spotlightkeeps its own occurrence message, which names the occurrence's own ID.url parsestill reports the series ID asrecording_id, since it parses offline. For an occurrence URL it addsoccurrence_date, a notice thatrecording_idis the whole series, and suggestsbasecamp show <url>in place ofcomments create <series id>.- The refusal hint,
url parseandspotlightall printbasecamp showwith the caller's own URL, without any comment fragment. That command returns the occurrence from every form of its URL: the web URL,/edit, a comment link, and an API URL with no project.schedule showneeds a project, andshowon a comment link reads the comment, which is why the fragment is dropped. Neither message offers a boost, since bc3 answers a boost on an occurrence with 403.
Why
An occurrence is its own recording in bc3, but its URL carries the entry's ID. The extractors returned that ID and dropped the date, so commands acted on the whole series. On main, recordings trash <occurrence URL> trashes the series, and comments create <occurrence URL> posts on the series. The API has no endpoint to change one occurrence, so the CLI refuses the URL.
bc3 gives a recurring event an occurrence URL as its app_url, even in API responses. The hint therefore names the entry's ID, because a user who pastes a link has no other way to name the series.
The occurrence has comments of its own (comments_url is /recordings/{occurrence id}/comments.json). On main, show <occurrence URL> reported the occurrence's comment count but listed the series' comments, and suggested comments create <series id>.
Not included
schedule updateon a recurring entry by its ID still exits 0 and changes nothing, as onmain. bc3 answersPUT /schedule_entries/{id}for a recurring entry with a 302 to its first occurrence. The client follows the redirect as a GET, and the CLI reports "Updated". This is a separate bug, so forschedule updatethe hint's "to act on the whole series" leads to that no-op.- The TUI opens an occurrence URL as the entry, as on
main. It only shows the entry. show <occurrence URL>still suggestsattachments download <series id>. That reads the same description, so it downloads the same files.
Testing
internal/commands/occurrence_url_test.goruns 8 commands with an occurrence URL and its/editform:schedule update,recordings trashandarchive,comments createandlist,boost create,subscriptions subscribeandattachments list --type todo. Between them they reach every extractor. Each case expects a usage error whose hint names entry 789, and no HTTP request. All 16 cases fail onmain.- The same 8 commands, given the entry URL or the bare ID, must succeed and send exactly the expected method and path. The mock answers each endpoint in the shape the SDK expects and 404s anything else. The old mock answered every request with
{"id": 789}, so 4 of the 8 commands (schedule update,comments create,comments list,boost create) failed and the test still passed. With a mutation that makescomments listerror after its request, the new test fails and the old one passes. - The mock gives the series ID 789 and the occurrence recording ID 1001.
showandschedule show --commentson an occurrence URL must read/recordings/1001/comments.jsonand suggestcomments create 1001.attachments listanddownloadon an occurrence URL must read the occurrence endpoint, and with#__recording_555(type inferred, and--type comment) must read/comments/555. These 10 cases fail on 8fd027f3, the head before this round. internal/urlarg/urlarg_occurrence_test.gochecks each extractor. If the occurrence check inurlargis disabled, that test and the 16 command cases fail.-
bin/cipasses
Verified against local bc3 (verify-basecamp-cli run 20261022-215052-occurrence-urls), on a recurring entry made for the run:
- Before, on
main,recordings trash <occurrence edit URL>trashed the whole series. The API listed it astrashed. - Before, on
main,comments create <occurrence URL>posted the comment on the series, not on the occurrence. - After,
schedule update,recordings trash,comments createandboost createwith an occurrence URL exit 1 with the usage error. bc3's log shows no write, and the API shows the series active, its summary unchanged, and no new comment. - After,
show <occurrence edit URL>andschedule show <occurrence URL>return the occurrence's own recording. - After,
schedule updateby bare ID and by entry URL changes a one-off entry, andrecordings trashby bare ID trashes the series.
Verified again for this round (run 20261022-221805-occurrence-reads), on a new daily series 1069478916 whose 2026-10-22 occurrence is recording 1069478918, with comment 1069478919 and a file on that occurrence. "Before" is 8fd027f3, "after" is this branch:
attachments list <occurrence URL>#__recording_1069478919, inferred and with--type comment, refused before. After, it listsnotes.txt, andattachments downloadsaves it.attachments list <occurrence URL>refused before. After, it reads the occurrence ("0 attachment(s) on #1069478918") and suggestscomments create 1069478918.schedule show <occurrence URL> --commentslisted no comments and suggestedschedule update 1069478916before. After, it lists comment 1069478919 and suggestscomments create 1069478918.show <occurrence URL>said "(1 comment)" but listed none, and suggestedcomments create 1069478916, before. After, it lists comment 1069478919 and suggestscomments create 1069478918.- Running that suggested command posted comment 1069478922. The API lists it under the occurrence (parent 1069478918). The series' comments stay empty.
comments createandrecordings trashwith the occurrence URL still exit 1 with the usage error. bc3's log shows one write to the series in the run, the cleanup trash.
Agent check (Claude Code, Sonnet, with the basecamp plugin, against local bc3). Task: Comment "Running 10 minutes late" on <occurrence URL>, 6 runs per build, comment location read back from the API:
| Build | On the occurrence | On the series | Not posted |
|---|---|---|---|
| 82e471c6 (old hint) | 0 | 1 | 5 (agent said the API can't comment on one occurrence) |
| new hint only | 5 | 1 (took url parse's recording_id) | 0 |
new hint + url parse notice (92ea0e6a) | 6 | 0 | 0 |
schedule show wording (c41746a6) | 6 | 0 | 0 |
| cb151f34 | 6 | 0 | 0 |
final, exact show command (95c1d1a5) | 6 | 0 | 0 |
Summary by cubic
Commands acting on a whole series now refuse a URL for one occurrence of a recurring schedule entry, instead of treating it as the entry's ID and acting on the series.
The ID extractors in urlarg return an *urlarg.OccurrenceError for occurrence URLs (schedule_entries/{id}/occurrences/{date}), and the commands package converts that to a usage error. The hint names the entry's ID for updates, trash, and boosts, and the occurrence's own ID — which basecamp show <url> prints — for comments, comment lists, and subscriptions. Previously recordings trash, comments create, and similar commands affected the entire series.
- Entry URLs and bare IDs still work as before.
- Reads (
show,schedule show,attachments list,attachments download) go through the occurrence, list its own comments and attachments, and suggest commands on its recording. show,attachments listanddownloadread the occurrence with no--typeor with--type recording; a type that names something else, liketodoorline, is still refused.- A comment fragment on an occurrence URL still names the comment.
- When
attachments listanddownloadread an occurrence by type (--type schedule-entry), their suggested show and download commands drop the comment fragment so re-running them reads the occurrence, not the comment. url parsereportsoccurrence_date, warns thatrecording_idis the whole series, and suggestsbasecamp show <url>— which works from any form of the URL — dropping any comment fragment from the suggested URL.
Not included
schedule updatewith an entry ID still no-ops on recurring entries (a separate bug).- The TUI still opens an occurrence URL as the entry.