# Applier

{% tabs %}
{% tab title="End users" %}
Applier is the part of CrossWatch that actually writes changes.

It takes a plan and calls provider APIs to add or remove items.

#### What you will notice

* Logs show `apply:add:*` and `apply:remove:*` events.
* Large runs may show `apply:*:progress` events (chunking).
* Failed writes show up as `unresolved` counts.

#### When to care

* High `unresolved` usually means the destination rejected items.
* If a provider is flaky, applier may retry and then give up.
* Dry runs should show planning, but no real writes.

Related:

* Write paths: [One-way sync](/blueprint-architecture/orchestrator/one-way-sync.md), [Two-way sync](/blueprint-architecture/orchestrator/two-way-sync.md)
* Why writes get suppressed: [Guardrails](/blueprint-architecture/orchestrator/guardrails.md)
* Why “failed items” stop retrying: [Unresolved](/blueprint-architecture/orchestrator/unresolved.md)
  {% endtab %}

{% tab title="Power users" %}
Applier is the orchestrator’s write engine.

It calls provider `add()` and `remove()`.

It retries failures.

It can chunk large batches.

It normalizes provider responses into one result shape.

It records unresolved items when writes cannot be confirmed.

Code: `cw_platform/orchestrator/_applier.py`

Used by: [One-way sync](/blueprint-architecture/orchestrator/one-way-sync.md) and [Two-way sync](/blueprint-architecture/orchestrator/two-way-sync.md)

***

### What it does (and what it does not do)

✅ It does:

* call provider write methods with retries
* split large item lists into chunks
* normalize provider result shapes into one dict
* emit progress and summary events
* record unresolved items via `record_unresolved()`

❌ It does not:

* verify writes by re-reading the destination
* compute diffs or guardrails
* apply tombstones, blackbox, or phantom guard logic
* run writes concurrently (it is sequential by design)

***

### Entry points

#### `apply_add(...)`

```py
apply_add(
  dst_ops, cfg, dst_name, feature,
  items, dry_run,
  emit, dbg,
  chunk_size, chunk_pause_ms
) -> dict[str, Any]
```

Emits:

* `apply:add:start` (attempted count)
* `apply:add:progress` (per chunk, when chunking)
* `apply:add:done` (counts + normalized `result`)

#### `apply_remove(...)`

Same signature.

It calls `dst_ops.remove(...)`.

It emits:

* `apply:remove:start`
* `apply:remove:progress`
* `apply:remove:done`

***

### Provider contract (what applier expects)

The applier calls provider write methods on an `InventoryOps` instance:

```py
dst_ops.add(cfg, items, feature=..., dry_run=...) -> dict | None
dst_ops.remove(cfg, items, feature=..., dry_run=...) -> dict | None
```

The provider return value is flexible.

Applier looks for these keys.

#### A) Success indicator

* `ok` (bool, default: `True`)

#### B) Confirmations

Preferred:

* `confirmed` (int)
* `confirmed_keys` (list\[str]) (best case)

Fallbacks (used only when `confirmed` is missing):

* `count` (int)
* `added` (int) for adds
* `removed` (int) for removes

#### C) Unresolved

* `unresolved` can be:
  * a list of item dicts (preferred), or
  * a number

#### D) Errors

* `errors` (int)

Other keys are preserved.

Only these keys affect normalization and reporting.

***

### Normalized result shape

Both add and remove return the same structure:

```json
{
  "ok": true,
  "attempted": 25,
  "confirmed": 23,
  "confirmed_keys": ["imdb:tt...", "..."],
  "skipped": 1,
  "unresolved": 1,
  "errors": 0,
  "count": 23
}
```

Rules:

* `attempted` = `len(items)`
* `count` is always `confirmed` (alias)
* `skipped` = `attempted - confirmed - unresolved - errors` (clamped to ≥ 0)

***

### Confirmation rules (how `confirmed` is derived)

Normalization logic in `_normalize(...)`:

1. If provider returns `confirmed`, use it.
2. Else if `confirmed_keys` exists, use `len(confirmed_keys)`.
3. Else if `ok` is true, use the first non-empty of:
   * `count`, `added`, `removed`
4. Else, treat confirmed as `0`.

If a provider returns `ok=true` but no counts:

* `confirmed` becomes `0`
* everything looks “skipped”

For accurate reporting, return one of:

* `confirmed_keys`
* `confirmed`
* `count`

***

### Unresolved handling

Unresolved items are how applier prevents infinite “retry every run” loops.

If the provider response contains a non-empty `unresolved` list:

* applier emits `apply:unresolved`
* applier attempts `record_unresolved(dst, feature, ...)`

Recording rules:

#### 1) Provider returns unresolved items with IDs

If an unresolved entry is an item dict with `ids` containing one of:

* `imdb`, `tmdb`, `tvdb`, `slug`

Then applier records those items.

It tags them as:

* `apply:add:provider_unresolved` or `apply:remove:provider_unresolved`

#### 2) Provider returns unresolved items but they are not mappable

If it cannot derive stable tokens, it records nothing.

#### 3) Fallback: “confirmed == 0 and we tried things”

If `confirmed == 0` and `attempted > 0`, applier records all attempted items.

This is intentionally pessimistic.

It tags them as:

* `apply:add:fallback_unresolved` or `apply:remove:fallback_unresolved`

Files:

* `record_unresolved()` writes `*.unresolved.pending.json` under `/config/.cw_state/`.

Related: [Unresolved](/blueprint-architecture/orchestrator/unresolved.md) and [State](/blueprint-architecture/orchestrator/state.md)

***

### Retry behavior

All provider calls are wrapped in `_retry(...)`:

* attempts: `3`
* base sleep: `0.5s`
* backoff: `0.5s`, `1.0s`, `2.0s`

It retries on any exception.

It does not retry on `ok=false` return values.

That policy belongs to the provider.

***

### Chunking (large batches)

Chunking happens in `_apply_chunked(...)`.

Rules:

* If `chunk_size <= 0` or `total <= chunk_size`: one call.
* Else:
  * split into slices of `chunk_size`
  * call provider once per chunk
  * aggregate totals across chunks
  * append `confirmed_keys` across chunks
  * emit `{tag}:progress` after each chunk

Optional pause:

* if `chunk_pause_ms > 0`, it sleeps after each chunk.

Chunk size selection is orchestrator-owned.

The orchestrator usually calls:

* `effective_chunk_size(ctx, provider)` from `cw_platform/orchestrator/_chunking.py`
* and uses `ctx.apply_chunk_pause_ms` as a global throttle

***

### Events emitted

Common payload keys:

* `dst`, `feature`, `count`, `attempted`, `skipped`, `unresolved`, `errors`, `result`

Events:

* `apply:add:start`
* `apply:add:progress`
* `apply:add:done`
* `apply:remove:start`
* `apply:remove:progress`
* `apply:remove:done`
* `apply:unresolved`

Events go through the orchestrator `Emitter`.

They land in logs and the UI event stream.

***

### Provider checklist

If you implement a provider, do this:

* Return `confirmed_keys` whenever possible.
* Otherwise return `confirmed` or `count`.
* Return `unresolved` as item dicts with stable `ids`.
* Avoid `ok=true` with no counts.

If you return `None`, applier treats it as `{}`.

That usually makes runs look like no-ops.

***

### Related pages

* Where diffs come from: [Planner](/blueprint-architecture/orchestrator/planner.md)
* Where applies happen: [One-way sync](/blueprint-architecture/orchestrator/one-way-sync.md), [Two-way sync](/blueprint-architecture/orchestrator/two-way-sync.md)
* Guardrails around deletes: [Guardrails](/blueprint-architecture/orchestrator/guardrails.md)
* Write failure suppression: [Blackbox](/blueprint-architecture/orchestrator/blackbox.md), [Unresolved](/blueprint-architecture/orchestrator/unresolved.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/applier.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.
