Fail when Basecamp drops a requested assignee

Merged
RO
15h ago
+301-18

basecamp cards create … --to <column id> exited 0 and created an unassigned card. On cards create, --to is the assignee, and Basecamp answers success while silently dropping any assignee_ids entry it can't assign on that recording. Every command that sets assignees had the same hole, and cards update/todos update were worse: they replace the set, so a bad ID cleared the real assignees too.

What bc3 does (measured against local bc3)

Command, bad assigneeBeforeAfter
cards create --to <column id>exit 0, card unassignedexit 1, names card #, dropped ID
cards create --assignee <account person not on project>exit 0, unassignedexit 1
cards update --assignee <column id> on a card assigned to DHHexit 0, DHH removedexit 1, says it now has no assignees
cards step create, todos create/update, subtasks create, assign (to-do, card)exit 0, droppedexit 1

bc3 filters every assignee write through recording.reachable_people(include_admissible_people: true): project members, clients when the recording is visible to them, agents, and any admissible employee on an all-access project. Ids outside that set are ignored, not rejected.

The fix

Each write compares the IDs it sent with the assignees on the record Basecamp returns. Missing ones fail with a usage error that names the record, the dropped IDs and what it was saved with, and a hint that assigns that record (so an agent doesn't create a second one):

card #1069479860 was saved, but Basecamp did not assign 822400739: not a person who can be assigned there; it has no assignees
hint: Assign it with basecamp assign 1069479860 --card --to <person>, naming someone from basecamp people list --project <project>

Why check after the write rather than validate before it: no client-side lookup can reproduce bc3's rule. /people.json passes a person who isn't on the project (measured: dropped), and the project's people list would reject admissible people on an all-access project that bc3 accepts. The response is the authority, and reading it costs no extra request. The record exists by the time we fail; that matches the existing card %d created but assignment failed path in cards create.

Covered: cards create, cards update, cards step create/update, todos create/update, subtasks create/update, assign (to-do, --card, --step). unassign can't drop an ID and is untouched; the TUI picks from a people list and is out of scope. Name and email resolution is unchanged.

The second commit covers a mixed assign A B batch, where the person is kept on A and dropped on B. Partial batches still exit 0, which is the tested contract, but --agent prints only data, so B's failure was invisible. The batch now carries a diagnostic naming each failed item and why; quiet/agent mode writes it to stderr.

cards create --to stays as is. Its help now reads "Person to assign (alias for --assignee); the column is --column", since cards move --to is a column.

Verification

  • TestAssigneeWritesFailWhenBasecampDropsTheAssignee drives all 11 write paths against a mock that drops the ID; all 11 failed before the fix. TestAssigneeWritesSucceedWhenBasecampKeepsTheAssignee is the paired control. Three existing fixtures answered writes without the assignees they were sent; they now echo them, as bc3 does.
  • Live against local bc3 (verify-cli runs 20261027-235034-assignee-dropped before/after, 20261027-235248-assignee-kept for all 11 paths with valid people, 20261027-235440-assign-batch-mixed). Created records trashed.
  • bin/ci green.

Summary by cubic

Commands that assign people now fail when Basecamp silently drops a requested assignee, instead of exiting 0 with an unassigned or de-assigned record.

  • Bad IDs such as a column ID passed to cards create --to, or a person not on the project, now produce a usage error naming the record, the dropped IDs, and what the record was saved with.
  • cards update/todos update previously replaced the assignee set, so a bad ID also cleared the real assignees; that is now caught too.
  • The check compares the requested IDs against the assignees on the record Basecamp returns, so no extra request and no client-side lookup that could diverge from bc3's assignment rules.
  • Covered across cards create/update, cards step create/update, todos create/update, subtasks create/update, and assign (to-do, card, step).
  • Partial assign batches still exit 0, but under --agent/--quiet they now write a diagnostic to stderr naming each item that dropped the assignee.
  • The recovery hint keeps the assignees the record saved (subtasks update replaces the set) and names the --project flag, which assign requires.