Appearance
date: 2026-06-14 tags: [todoist, pagination, reconcile, data-loss] status: active graduated_to:
Paginate Todoist list endpoints to exhaustion — "absent" ≠ "deleted"
Symptom — a check-reasoning red-team caught that the occasions reconcile backstop would delete real tasks on a perfectly healthy account: any open occasion task beyond the first page of getAllTasks() was treated as "deleted in Todoist" and tombstoned.
Root cause — HttpTodoistClient::getAllTasks() did a single GET /tasks and read ->json('results'), ignoring next_cursor. Todoist v1 list endpoints return { results, next_cursor } and page at ~200. The reconcile diff reads "a ledger-open task absent from getAllTasks()" as a deletion — so a partial list = phantom deletions = a task-shredder, no outage required.
Fix (fd849e1 / the Sprint B reconcile work) — getAllTasks() now loops next_cursor to exhaustion before returning. Defence-in-depth already in the reconciler: an empty/failed fetch is treated as "no answer" (never "all gone"), and a run over the blast-radius cap is blocked.
Guard — tests/Feature/TodoistClientTest.php: a two-page next_cursor fixture asserts getAllTasks() returns rows from both pages. Any new consumer of a Todoist list endpoint must paginate; never treat a single-page fetch as the complete set, and never read "absent from a fetch" as "deleted" without a non-empty, fully-paginated answer.