When the same fact is recorded in two places, they can drift. Tool A says item x_42 is done, tool B says it's pending. The drift is silent until something breaks.
The pattern: read both, compare, surface the diff.
tool_a_state = {"x_1": "done", "x_2": "done", "x_3": "pending", "x_4": "done"}
tool_b_state = {"x_1": "done", "x_2": "pending", "x_3": "pending", "x_4": "done"}
drift = []
for item_id, status_a in tool_a_state.items():
status_b = tool_b_state.get(item_id)
if status_a != status_b:
drift.append({"id": item_id, "a": status_a, "b": status_b})
print(drift)Expected: drift on x_2 (A says done, B says pending).
Why would two tools have different state in the first place?
Lots of reasons. A pipeline that updates A first then B can crash between them, leaving A advanced and B not. A user can manually edit one. Network errors mean a write that succeeded in A failed in B. Real systems drift constantly.
And what do you do with the drift?
Three options, in increasing intervention:
For most automations: option 1, run as a scheduled job, look at the report. Option 2 is automation-grade. Option 3 is for high-stakes systems where drift means a real bug.
Any pipeline that updates two systems with the same fact will eventually drift:
Drift between two systems is normal. Detecting and resolving it is the work.
def detect_drift(state_a: dict, state_b: dict) -> list:
"""Return list of {id, a, b} for ids where a and b disagree."""
drift = []
all_ids = set(state_a) | set(state_b)
for item_id in all_ids:
a = state_a.get(item_id)
b = state_b.get(item_id)
if a != b:
drift.append({"id": item_id, "a": a, "b": b})
return driftThe set(a) | set(b) union ensures you catch IDs missing from one side entirely (= None on that side).
Declare one tool the primary; the other is a derived view. On drift, copy primary → secondary:
for row in drift:
if row["a"] != row["b"]:
update_b(row["id"], row["a"]) # A is canonicalSimplest mental model. Common in production — the database is canonical, the cache/Sheet is derived.
If both tools track the same fact independently and either could be the latest, use timestamps:
for row in drift:
if row["a_ts"] > row["b_ts"]:
update_b(row["id"], row["a"])
else:
update_a(row["id"], row["b"])Requires both tools to track timestamps. Rarely the right choice for ops/state; common for content (e.g., contact info synced across CRM tools).
For anything irreversible (sending money, deleting accounts), don't auto-reconcile — surface the drift for human review.
The ideal: a single transactional write that updates both tools atomically. Reality: most APIs don't support this across tool boundaries.
Mitigation: use a single source of truth, derive the other from it. The Sheet is canonical; the Tasks list is rebuilt from the Sheet on each run.
This trades drift-detection for re-build cost. Often worth it.
A periodic check is the sweet spot. Run it nightly, alert if drift count > threshold, fix daily.
If you copy A → B and B → A, you can ping-pong forever — every "fix" creates new "drift" the other direction sees. Always declare a direction (or use timestamps + last-write-wins to break ties).
When the same fact is recorded in two places, they can drift. Tool A says item x_42 is done, tool B says it's pending. The drift is silent until something breaks.
The pattern: read both, compare, surface the diff.
tool_a_state = {"x_1": "done", "x_2": "done", "x_3": "pending", "x_4": "done"}
tool_b_state = {"x_1": "done", "x_2": "pending", "x_3": "pending", "x_4": "done"}
drift = []
for item_id, status_a in tool_a_state.items():
status_b = tool_b_state.get(item_id)
if status_a != status_b:
drift.append({"id": item_id, "a": status_a, "b": status_b})
print(drift)Expected: drift on x_2 (A says done, B says pending).
Why would two tools have different state in the first place?
Lots of reasons. A pipeline that updates A first then B can crash between them, leaving A advanced and B not. A user can manually edit one. Network errors mean a write that succeeded in A failed in B. Real systems drift constantly.
And what do you do with the drift?
Three options, in increasing intervention:
For most automations: option 1, run as a scheduled job, look at the report. Option 2 is automation-grade. Option 3 is for high-stakes systems where drift means a real bug.
Any pipeline that updates two systems with the same fact will eventually drift:
Drift between two systems is normal. Detecting and resolving it is the work.
def detect_drift(state_a: dict, state_b: dict) -> list:
"""Return list of {id, a, b} for ids where a and b disagree."""
drift = []
all_ids = set(state_a) | set(state_b)
for item_id in all_ids:
a = state_a.get(item_id)
b = state_b.get(item_id)
if a != b:
drift.append({"id": item_id, "a": a, "b": b})
return driftThe set(a) | set(b) union ensures you catch IDs missing from one side entirely (= None on that side).
Declare one tool the primary; the other is a derived view. On drift, copy primary → secondary:
for row in drift:
if row["a"] != row["b"]:
update_b(row["id"], row["a"]) # A is canonicalSimplest mental model. Common in production — the database is canonical, the cache/Sheet is derived.
If both tools track the same fact independently and either could be the latest, use timestamps:
for row in drift:
if row["a_ts"] > row["b_ts"]:
update_b(row["id"], row["a"])
else:
update_a(row["id"], row["b"])Requires both tools to track timestamps. Rarely the right choice for ops/state; common for content (e.g., contact info synced across CRM tools).
For anything irreversible (sending money, deleting accounts), don't auto-reconcile — surface the drift for human review.
The ideal: a single transactional write that updates both tools atomically. Reality: most APIs don't support this across tool boundaries.
Mitigation: use a single source of truth, derive the other from it. The Sheet is canonical; the Tasks list is rebuilt from the Sheet on each run.
This trades drift-detection for re-build cost. Often worth it.
A periodic check is the sweet spot. Run it nightly, alert if drift count > threshold, fix daily.
If you copy A → B and B → A, you can ping-pong forever — every "fix" creates new "drift" the other direction sees. Always declare a direction (or use timestamps + last-write-wins to break ties).
Create a free account to get started. Paid plans unlock all tracks.