# Scope

{% tabs %}
{% tab title="End users" %}
Scope prevents “state bleed” between different pairs.

It keeps guardrail files separate across:

* pair direction (one-way vs two-way)
* provider combination
* pair index/id

#### When scope matters

You’ll feel it when you:

* duplicate a pair
* change a pair’s mode
* clear cache and expect only one pair to reset

#### Quick troubleshooting

If you see files named `...unscoped...`:

* the run didn’t set scope correctly
* guardrail files may collide across pairs

Related:

* Where these files live: [State](/blueprint-architecture/orchestrator/state.md)
* The most scope-sensitive guardrails: [Phantom Guard](/blueprint-architecture/orchestrator/phantom-guard.md), [Unresolved](/blueprint-architecture/orchestrator/unresolved.md)
  {% endtab %}

{% tab title="Power users" %}
This doc explains how the orchestrator scopes state/guardrail files per pair and feature, and why you sometimes see filenames that look like a password.

<details>

<summary>Implementation notes</summary>

**Core code:** `cw_platform/orchestrator/_scope.py`\
**Where scope is set:** `cw_platform/orchestrator/_pairs.py` (`_pair_env`)\
**Used by:** `_phantoms.py`, `_blackbox.py`, `_unresolved.py`, `_snapshots.py` (shadow), and other helpers.

</details>

***

### Why scope exists

Without scoping, guardrail state would collide:

* A phantom from `PLEX → SIMKL` watchlist would block `PLEX → TRAKT` too.
* A blackbox entry from one feature could affect a different feature.
* Multiple configured pairs (pair 0, pair 1…) would share the same cooldown files.

Scope makes these state files:

* **pair-specific**
* **mode-specific** (one-way vs two-way)
* and usually **feature-specific**

So failures don’t leak across unrelated sync paths.

***

### What “scope” is (in practice)

Scope is a short string derived from env vars the orchestrator sets right before running a feature.

Typical scopes look like:

* `one-way_plex-simkl_0`
* `two-way_plex-trakt_1`

There’s also:

* `health` scope (temporary) during the health pass
* a fallback “unscoped/default” when scope isn’t set (should be rare in real runs)

***

### Where scope comes from

In `_pairs.py`, before each feature run, orchestrator calls `_pair_env(...)` which sets env vars:

* `CW_PAIR_SCOPE` ← the main scope string
* `CW_PAIR_KEY` ← the provider-pair key (providers sorted)
* `CW_PAIR_SRC` / `CW_PAIR_DST`
* `CW_PAIR_MODE` ← `one-way` or `two-way`
* `CW_PAIR_FEATURE` ← `watchlist`, `ratings`, etc.
* (compat) `CW_PAIR`, `CW_SYNC_PAIR`

#### Scope key construction

* pair key: `"-".join(sorted([src, dst]))` (e.g., `PLEX-SIMKL`)
* index/id: uses `pair.get("id")` if present, else config index `i`
* final:
  * `"{mode}:{pair_key}:{id_or_index}"`

Then it gets sanitized to be filename-safe:

* lowercased
* non `[a-z0-9._-]` replaced with `_`
* `:` becomes `_`
* capped to 96 characters

So:

* `two-way:PLEX-SIMKL:0` → `two-way_plex-simkl_0`

***

### `_scope.py` helpers

#### `pair_scope() -> str`

Returns `os.environ["CW_PAIR_SCOPE"]` if set, else `"unscoped"`.

#### `pair_key() -> str`

Returns `os.environ["CW_PAIR_KEY"]` if set, else `"unscoped"`.

#### `scoped_file(prefix: str, ext: str) -> str`

Returns a full path under `.cw_state`:

* `/config/.cw_state/{prefix}.{scope}.{ext}`

Example:

* `scoped_file("SIMKL_watchlist", "blackbox.json")` → `/config/.cw_state/SIMKL_watchlist.one-way_plex-simkl_0.blackbox.json`

This is used heavily by guardrail modules.

***

### Which files are scoped vs global

#### Usually scoped

* PhantomGuard:
  * `watchlist.{src}-{dst}.{scope}.phantoms.json`
  * `watchlist.{src}-{dst}.{scope}.last_success.json`
* Unresolved:
  * `{dst}_{feature}.{scope}.unresolved.pending.json`
  * (expected) `{dst}_{feature}.{scope}.unresolved.json`
* Blackbox flap counters:
  * `{dst}_{feature}.{scope}.flap.json`
* Some blackbox entries (depending on config / pair\_scoped):
  * either `{dst}_{feature}.{pair}.blackbox.json` or `{dst}_{feature}.{scope}.blackbox.json`
* ANILIST shadow:
  * `anilist_watchlist_shadow.{scope}.json`

#### Not scoped

* `/config/state.json`, `/config/state.manual.json`, `/config/last_sync.json`
* `/config/.cw_state/tombstones.json` (single file, but entries are feature+pair-scoped *inside* its keys)

***

### Pair key vs scope (they’re not the same)

Two different concepts:

#### Pair key

* `"PLEX-SIMKL"` (providers sorted)
* used to share state across multiple configured “pairs” that happen to be the same provider combo

Blackbox supports pair-key scoping (optional) because:

* flappers tend to be “provider combo”-specific, not “config row”-specific

Tombstones always use pair key inside the JSON key.

#### Scope

* includes mode and pair index/id
* isolates state per configured pair

PhantomGuard uses scope to avoid cross-pair contamination.

***

### Common confusion patterns

#### “Why does my file name include the pair index?”

Because scope includes `pair_id_or_index`. If you duplicate a pair in config, you get separate scope files.

#### “Why do I have multiple phantom files for the same provider pair?”

Because different pair entries (or mode) generate different scopes.

#### “Why is there a blackbox file with PAIR but not SCOPE?”

Because `_blackbox.py` can be configured to store entries per pair key:

* `{dst}_{feature}.{PAIR}.blackbox.json`

This is separate from flap counters, which are usually scoped.

***

### Debugging scope issues

Inside the container, while a run is in progress, you can check:

```bash
echo "$CW_PAIR_SCOPE"
echo "$CW_PAIR_KEY"
echo "$CW_PAIR_MODE $CW_PAIR_SRC -> $CW_PAIR_DST ($CW_PAIR_FEATURE)"
```

If you see “unscoped” during a real feature run, something isn’t setting env correctly.

To inspect all guardrail files for a specific pair run:

```bash
ls -lah /config/.cw_state/*one-way_plex-simkl_0*
```

To find which files belong to a pair key (regardless of scope):

```bash
ls -lah /config/.cw_state/*PLEX-SIMKL*
```

***

### Operational advice

* If you want a guardrail to apply across all config rows for the same provider combo, use **pair key**.
* If you want it isolated per configured pair, use **scope**.
* Don’t mix the two in one mechanism unless you’re very sure; it’s how you get “why is TRAKT blocked by SIMKL’s failures” moments.

***

### Related pages

* File locations and naming patterns: [State](/blueprint-architecture/orchestrator/state.md)
* PAIR vs SCOPE behavior: [Blackbox](/blueprint-architecture/orchestrator/blackbox.md)
* Scope-heavy files: [Phantom Guard](/blueprint-architecture/orchestrator/phantom-guard.md), [Unresolved](/blueprint-architecture/orchestrator/unresolved.md)
* Pair key usage for tombstones: [Two-way sync](/blueprint-architecture/orchestrator/two-way-sync.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/scope.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.
