Refuse occurrence URLs instead of acting on the whole series

RO
1h ago
+1076-182

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:

  • show and schedule show read the URL with Parse, 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 show drops its schedule update breadcrumb for an occurrence, since the API cannot update one.
  • attachments list and download read the occurrence through GET /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 --type other than a schedule entry or a comment, the URL is refused.
  • spotlight keeps its own occurrence message, which names the occurrence's own ID.
  • url parse still reports the series ID as recording_id, since it parses offline. For an occurrence URL it adds occurrence_date, a notice that recording_id is the whole series, and suggests basecamp show <url> in place of comments create <series id>.
  • The refusal hint, url parse and spotlight all print basecamp show with 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 show needs a project, and show on 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 update on a recurring entry by its ID still exits 0 and changes nothing, as on main. bc3 answers PUT /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 for schedule update the 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 suggests attachments download <series id>. That reads the same description, so it downloads the same files.

Testing

  • internal/commands/occurrence_url_test.go runs 8 commands with an occurrence URL and its /edit form: schedule update, recordings trash and archive, comments create and list, boost create, subscriptions subscribe and attachments 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 on main.
  • 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 makes comments list error 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. show and schedule show --comments on an occurrence URL must read /recordings/1001/comments.json and suggest comments create 1001. attachments list and download on 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.go checks each extractor. If the occurrence check in urlarg is disabled, that test and the 16 command cases fail.
  • bin/ci passes

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 as trashed.
  • Before, on main, comments create <occurrence URL> posted the comment on the series, not on the occurrence.
  • After, schedule update, recordings trash, comments create and boost create with 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> and schedule show <occurrence URL> return the occurrence's own recording.
  • After, schedule update by bare ID and by entry URL changes a one-off entry, and recordings trash by 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 lists notes.txt, and attachments download saves it.
  • attachments list <occurrence URL> refused before. After, it reads the occurrence ("0 attachment(s) on #1069478918") and suggests comments create 1069478918.
  • schedule show <occurrence URL> --comments listed no comments and suggested schedule update 1069478916 before. After, it lists comment 1069478919 and suggests comments create 1069478918.
  • show <occurrence URL> said "(1 comment)" but listed none, and suggested comments create 1069478916, before. After, it lists comment 1069478919 and suggests comments create 1069478918.
  • Running that suggested command posted comment 1069478922. The API lists it under the occurrence (parent 1069478918). The series' comments stay empty.
  • comments create and recordings trash with 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:

BuildOn the occurrenceOn the seriesNot posted
82e471c6 (old hint)015 (agent said the API can't comment on one occurrence)
new hint only51 (took url parse's recording_id)0
new hint + url parse notice (92ea0e6a)600
schedule show wording (c41746a6)600
cb151f34600
final, exact show command (95c1d1a5)600

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 list and download read the occurrence with no --type or with --type recording; a type that names something else, like todo or line, is still refused.
  • A comment fragment on an occurrence URL still names the comment.
  • When attachments list and download read 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 parse reports occurrence_date, warns that recording_id is the whole series, and suggests basecamp show <url> — which works from any form of the URL — dropping any comment fragment from the suggested URL.

Not included

  • schedule update with an entry ID still no-ops on recurring entries (a separate bug).
  • The TUI still opens an occurrence URL as the entry.