# Conflicts

{% tabs %}
{% tab title="End users" %}
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.

{% hint style="warning" %}
If you don’t want “delete propagation”, keep **Remove** off in two-way.
{% endhint %}

#### 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](/blueprint-architecture/orchestrator/two-way-sync.md)
* Delete memory: [Tombstones](/blueprint-architecture/orchestrator/tombstones.md)
  {% endtab %}

{% tab title="Power users" %}
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.

<details>

<summary>Implementation notes</summary>

**Code:** `cw_platform/orchestrator/_pairs_twoway.py` (ratings + presence logic)

</details>

***

### 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`, OR
* typed tokens match something in `prevB_alias`, OR
* it 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_A` and key in `up_B`

Resolution:

1. Try comparing `rated_at` timestamps (if both parse):
   * later timestamp wins
2. Otherwise fall back to “source of truth”:
   * `sync.bidirectional.source_of_truth` (or `sourceOfTruth`)
   * must equal either provider name `A` or `B`
   * else defaults to `A`

Winner decides the direction:

* if winner is `A`: apply `up_B[k]` (push A → B)
* else winner is `B`: apply `up_A[k]` (push B → A)

#### 2.2 Conflict type: upsert vs unrate (one side removed rating)

Case A:

* key in `up_B` AND key in `unrate_A` Meaning:
* 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_removals` is enabled AND the item hits tombstones (`_tokens(a_it) & tombX` or `k 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 → B
* Only in `up_A` → push B → A
* Only 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 `rating`
  * plus `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_remove`
* per-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](/blueprint-architecture/orchestrator/two-way-sync.md)
* Delete memory: [Tombstones](/blueprint-architecture/orchestrator/tombstones.md)
* Mass delete and suspect snapshot protection: [Guardrails](/blueprint-architecture/orchestrator/guardrails.md)
* Rating diff behavior: [Planner](/blueprint-architecture/orchestrator/planner.md)
  {% endtab %}
  {% endtabs %}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://wiki.crosswatch.app/blueprint-architecture/orchestrator/conflicts.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
