Appearance
date: 2026-06-14 tags: [reconcile, fingerprint, idempotency, churn] status: active graduated_to:
A drift fingerprint must exclude values that float with now()
Symptom — early in the reconcile design, a desired-state fingerprint that included the task's due date would have made every daily run re-detect drift and rewrite the task — an infinite churn that also fires a webhook each time.
Root cause — the occasion due is catch-up clamped: once the lead date has passed, due = max(eventDate − lead, today), so it advances with today. Put that in the drift hash and the hash changes every day on its own, independent of any real edit — the diff never converges.
Fix — DesiredOccasionTask::fingerprint() hashes only the stable desired state: content, the event deadline, description, label, and the code-knowledge version — deliberately not the floating due. The deadline plus version already capture every genuine date/lead change; a passing day alone never flips the hash. The clamped due is still written to Todoist on a real change, just not part of the drift signal.
Guard — tests/Feature/ReconcileOccasionTasksTest.php: a convergence test runs reconcile twice in a row and asserts the second pass writes nothing. General rule for any diff/fingerprint: hash the desired state, never a value derived from the current clock or a round-tripped read.