Skip to content

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.

FixDesiredOccasionTask::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.

Guardtests/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.