Conflicts
Two-way conflict resolution for ratings, adds vs deletes, and ambiguous “missing” states.
Conflicts happen in two-way sync.
One-way has a single source of truth.
The only conflicts most people should care about
Ratings changed on both sides: CrossWatch picks a winner.
Item present on one side but missing on the other: CrossWatch usually adds it.
Deletes vs adds: deletes only win when removes are enabled and evidence is strong.
If you don’t want “delete propagation”, keep Remove off in two-way.
If you see weird outcomes
missing IDs and mismatched keys cause “false conflicts”
stale snapshots can look like deletions
Related:
Two-way behavior: Two-way sync
Delete memory: Tombstones
This doc covers what the orchestrator considers a “conflict”, and how it resolves it (today).
Most of the “conflict” behavior lives in two-way planning (_pairs_twoway.py), because one-way doesn’t have to reconcile two sources of truth.
1) Presence features (watchlist / history / playlists)
Presence features are sync’d by “is it present or not”.
A conflict happens when:
one side is missing the item, and the other side has it, but there’s also evidence that the missing side is missing it because it was deleted (not just “not yet synced”).
Default behavior: add missing items
For each item in A_eff not present in B_eff:
plan
add_to_B
and vice versa for B → A.
Presence is checked with the typed-token alias logic:
canonical key match, OR
season/episode typed token match (
id:val#s01e02,id:val#season:1)
When a delete “wins”
If allow_removals is enabled, the orchestrator may plan a remove instead of an add.
It will plan rem_from_A (delete from A) for an item that exists in A but not in B when:
There is a strong deletion signal on B, AND
The item looks like it used to exist on B (to avoid deleting things that never lived there)
Deletion signals used (any of these):
token matches tombstones (
_tokens(item) & tombX)canonical key is tombstoned (
ck in tombX)key is in B observed deletions (
ck in obsB)key is in B shrink list (
ck in shrinkB) ← missing from B’s current snapshot vs baseline
“Used to exist” checks:
canonical key exists in
prevB, ORtyped tokens match something in
prevB_alias, ORit hits tombstones (counts as strong enough evidence)
Same logic mirrors for deleting from B.
Why this is safe-ish
Observed deletions are disabled on provider down or suspect snapshot.
Bootstrap (first run) forces removals off.
Mass delete protection can nuke large removal plans.
2) Ratings conflicts (two-way)
Ratings are the only feature with explicit write-vs-write conflict resolution.
Two-way builds two diffs:
up_B, unrate_B = diff_ratings(A_f, B_f, propagate_timestamp_updates=False)“write A’s ratings into B”
up_A, unrate_A = diff_ratings(B_f, A_f, propagate_timestamp_updates=False)“write B’s ratings into A”
Then it resolves per key in the union of:
upserts from both sides
unrates from both sides
2.1 Conflict type: upsert vs upsert (both sides changed)
Case:
key in
up_Aand key inup_B
Resolution:
Try comparing
rated_attimestamps (if both parse):later timestamp wins
Otherwise fall back to “source of truth”:
sync.bidirectional.source_of_truth(orsourceOfTruth)must equal either provider name
AorBelse defaults to
A
Winner decides the direction:
if winner is
A: applyup_B[k](push A → B)else winner is
B: applyup_A[k](push B → A)
2.2 Conflict type: upsert vs unrate (one side removed rating)
Case A:
key in
up_BAND key inunrate_AMeaning:A has a rating and wants to push it to B
B does not have a rating and wants A to unrate
Resolution:
If
allow_removalsis enabled AND the item hits tombstones (_tokens(a_it) & tombXork in tombX):deletion wins → unrate on A
Otherwise:
rating wins → push rating A → B
Case B is symmetric (up_A with unrate_B).
2.3 No conflict
Only in
up_B→ push A → BOnly in
up_A→ push B → AOnly in unrate_* and removals allowed → unrate that side
If removals disabled → unrates are ignored
2.4 What gets written
Two-way ratings writes use a minimal payload that keeps rating fields:
_minimal_keep_rating(item)keeps:minimal identity (
type/title/year/ids)plus
ratingplus
rated_at(when present)
So providers can apply rating updates without needing full metadata.
3) The config knobs that matter
Enable removals (deletes/unrates)
global:
sync.enable_removeper-feature override:
feature.remove
If removals are off:
presence deletes are not planned
unrates are not planned
Rating conflict preference
sync.bidirectional.source_of_truth (or sourceOfTruth)
Values:
"PLEX","SIMKL","TRAKT", etc. (must match one side of the pair)
Used only when rated_at can’t decide.
Deletion memory (tombstones)
sync.tombstone_ttl_days
Long TTL means “delete intent sticks longer”.
It can override rating-vs-unrate conflicts.
4) What conflict policy isn’t (yet)
There is a ConflictPolicy dataclass in _types.py and it’s attached to Orchestrator(conflict=...), but the current two-way logic does not consult ctx.conflict. Ratings resolution is implemented directly in _pairs_twoway.py.
So: if you’re looking for a single generic conflict engine — it’s not there (yet).
Related pages
Full two-way pipeline: Two-way sync
Delete memory: Tombstones
Mass delete and suspect snapshot protection: Guardrails
Rating diff behavior: Planner
Last updated