---
phase: 39-barkey-dataclass-clickhouse-layer
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/types.py
  - python/opendeviationbar/__init__.py
  - python/opendeviationbar/clickhouse/bulk_operations.py
  - python/opendeviationbar/clickhouse/query_operations.py
  - python/opendeviationbar/clickhouse/maintenance.py
  - python/opendeviationbar/clickhouse/bar_count_api.py
  - python/opendeviationbar/clickhouse/checkpoint_operations.py
  - python/opendeviationbar/clickhouse/cache.py
  - python/opendeviationbar/clickhouse/write_lock.py
autonomous: true
requirements: [BARKEY-01, BARKEY-02, BARKEY-05]

must_haves:
  truths:
    - "BarKey is importable from opendeviationbar and opendeviationbar.types"
    - "All 27 ClickHouse functions accept BarKey as first argument"
    - "All 27 ClickHouse functions still accept individual (symbol, threshold_decimal_bps, ouroboros_mode) params"
    - "All existing tests pass without modification"
    - "ruff check passes"
  artifacts:
    - path: "python/opendeviationbar/types.py"
      provides: "BarKey frozen dataclass"
      contains: "class BarKey"
      exports: ["BarKey"]
    - path: "python/opendeviationbar/__init__.py"
      provides: "Re-export of BarKey"
      contains: "BarKey"
  key_links:
    - from: "python/opendeviationbar/clickhouse/bulk_operations.py"
      to: "python/opendeviationbar/types.py"
      via: "from ..types import BarKey"
      pattern: "isinstance.*BarKey"
    - from: "python/opendeviationbar/__init__.py"
      to: "python/opendeviationbar/types.py"
      via: "from .types import BarKey"
---

<objective>
Create a frozen `BarKey(symbol, threshold_decimal_bps, ouroboros_mode)` dataclass and update all 27 ClickHouse layer functions to accept it, using expand-contract so existing callers continue working unchanged.

Purpose: Eliminate the (symbol, threshold_decimal_bps, ouroboros_mode) data clump passed to 37 functions across the codebase, starting with the ClickHouse layer (27 functions). Phase 40 will handle orchestration/kintsugi layers and Phase 42 will remove the deprecated individual-param paths.

Output: `python/opendeviationbar/types.py` with `BarKey`, all 27 CH functions accepting `BarKey | str` as first param.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@python/opendeviationbar/__init__.py
@python/opendeviationbar/clickhouse/bulk_operations.py
@python/opendeviationbar/clickhouse/query_operations.py
@python/opendeviationbar/clickhouse/maintenance.py
@python/opendeviationbar/clickhouse/bar_count_api.py
@python/opendeviationbar/clickhouse/checkpoint_operations.py
@python/opendeviationbar/clickhouse/cache.py
@python/opendeviationbar/clickhouse/write_lock.py

<interfaces>
<!-- Existing CacheKey in cache.py — BarKey is the simpler version without time bounds -->
From python/opendeviationbar/clickhouse/cache.py:
```python
@dataclass(frozen=True)
class CacheKey:
    symbol: str
    threshold_decimal_bps: int
    start_ts: int
    end_ts: int
    ouroboros_mode: str = "aion"
```

<!-- Mixin class hierarchy — all CH methods are on mixins inherited by OpenDeviationBarCache -->

OpenDeviationBarCache inherits: ClickHouseClientMixin, BulkStoreMixin, QueryOperationsMixin,
CheckpointOperationsMixin, MaintenanceMixin, BarCountMixin

<!-- Module-level functions (not methods) that also need BarKey -->

bulk_operations.py:\_guard_threshold_registry(symbol, threshold_decimal_bps)
bulk_operations.py:\_pre_write_overlap_and_mode_guard(cache, symbol, threshold_decimal_bps, bars, overlap_strategy, ouroboros_mode)
bulk_operations.py: \_check_overlap_strategy(cache, symbol, threshold_decimal_bps, bars_df, strategy, ouroboros_mode)
maintenance.py:\_get_partition_lock(symbol, threshold_decimal_bps)
maintenance.py: \_dead_letter_replace_failure(symbol, threshold_decimal_bps, bars_df, new_first, new_last)
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create BarKey dataclass and resolve helper, re-export from __init__.py</name>
  <files>python/opendeviationbar/types.py, python/opendeviationbar/__init__.py</files>
  <action>
Create `python/opendeviationbar/types.py` (per BARKEY-01):

```python
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class BarKey:
    """Identity key for a bar series: (symbol, threshold, ouroboros mode).

    This is the *time-free* identity triple that appears in every ClickHouse
    query, write lock, partition lock, and gap-detection call.  Unlike
    ``CacheKey`` (which adds ``start_ts`` / ``end_ts``), ``BarKey`` identifies
    the *series*, not a time window within it.

    Parameters
    ----------
    symbol : str
        Trading symbol (e.g. ``"BTCUSDT"``).
    threshold_decimal_bps : int
        Threshold in decimal basis points (1 dbps = 0.001%).
    ouroboros_mode : str
        Ouroboros reset mode.  Only ``"aion"`` is active.
    """

    symbol: str
    threshold_decimal_bps: int
    ouroboros_mode: str = "aion"
```

Also add a private resolve helper in the same file:

```python
def _resolve_bar_key(
    symbol_or_key: str | BarKey,
    threshold_decimal_bps: int | None = None,
    ouroboros_mode: str | None = None,
) -> BarKey:
    """Resolve expand-contract overloaded args into a BarKey.

    If ``symbol_or_key`` is already a ``BarKey``, returns it (ignoring the
    other two args unless they are explicitly provided as overrides).
    If it is a ``str``, builds a ``BarKey`` from the three individual params.

    Raises
    ------
    TypeError
        If ``symbol_or_key`` is a ``str`` but ``threshold_decimal_bps`` is
        ``None`` (required in the legacy path).
    """
    if isinstance(symbol_or_key, BarKey):
        return symbol_or_key
    # Legacy path — symbol is a str
    if threshold_decimal_bps is None:
        msg = "threshold_decimal_bps is required when passing symbol as str"
        raise TypeError(msg)
    if ouroboros_mode is None:
        from opendeviationbar.ouroboros import get_operational_ouroboros_mode
        ouroboros_mode = get_operational_ouroboros_mode()
    return BarKey(symbol_or_key, threshold_decimal_bps, ouroboros_mode)
```

In `__init__.py`, add the re-export alongside the existing imports (near the `from .exceptions import SymbolNotRegisteredError` line):

```python
from .types import BarKey
```

And add `"BarKey"` to the `__all__` list if one exists, otherwise just the import is sufficient.

Do NOT create a `.pyi` stub for `types.py` yet — the pretooluse hook blocks definitions in `__init__.pyi`, and per-module stubs can be added later.
</action>
<verify>
<automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 python -c "from opendeviationbar.types import BarKey, \_resolve_bar_key; k = BarKey('BTCUSDT', 250); print(k); assert k.symbol == 'BTCUSDT'; assert k.threshold_decimal_bps == 250; assert k.ouroboros_mode == 'aion'; from opendeviationbar import BarKey as BK; assert BK is BarKey; print('OK')"</automated>
</verify>
<done>BarKey is a frozen dataclass importable from both opendeviationbar.types and opendeviationbar. \_resolve_bar_key handles both str and BarKey inputs.</done>
</task>

<task type="auto">
  <name>Task 2: Update all 27 ClickHouse layer functions to accept BarKey via expand-contract</name>
  <files>
    python/opendeviationbar/clickhouse/bulk_operations.py,
    python/opendeviationbar/clickhouse/query_operations.py,
    python/opendeviationbar/clickhouse/maintenance.py,
    python/opendeviationbar/clickhouse/bar_count_api.py,
    python/opendeviationbar/clickhouse/checkpoint_operations.py,
    python/opendeviationbar/clickhouse/cache.py,
    python/opendeviationbar/clickhouse/write_lock.py
  </files>
  <action>
For each of the 27 functions below, apply the expand-contract pattern. The key mechanical change for each function:

1. Change `symbol: str` param to `symbol_or_key: str | BarKey`
2. Make `threshold_decimal_bps: int` default to `None` (i.e., `threshold_decimal_bps: int | None = None`)
3. Add 3 lines at the top of the function body to resolve:

   ```python
   from opendeviationbar.types import BarKey, _resolve_bar_key
   key = _resolve_bar_key(symbol_or_key, threshold_decimal_bps, ouroboros_mode)
   symbol, threshold_decimal_bps, ouroboros_mode = key.symbol, key.threshold_decimal_bps, key.ouroboros_mode
   ```

   This destructures back into locals so the rest of the function body is UNCHANGED.

4. For functions that already resolve `ouroboros_mode` from config when None, REMOVE that resolution block since `_resolve_bar_key` handles it.

**IMPORTANT**: The `_resolve_bar_key` import must be at function-call level (inside the function body), not at module level, to avoid circular imports. The `from __future__ import annotations` already present in each file ensures type annotations are strings.

Add `from __future__ import annotations` to files that don't already have it (all do).

Add `TYPE_CHECKING` import of `BarKey` at the top of each file for type annotation use:

```python
if TYPE_CHECKING:
    from opendeviationbar.types import BarKey
```

### File-by-file function list

**bulk_operations.py** (6 functions):

- L136: `_guard_threshold_registry(symbol, threshold_decimal_bps)` — module-level function, NOT a method
- L203: `_pre_write_overlap_and_mode_guard(cache, symbol, threshold_decimal_bps, ...)` — module-level
- L264: `_check_overlap_strategy(cache, symbol, threshold_decimal_bps, ...)` — module-level
- L404: `_guard_ouroboros_mode_consistency(self, symbol, threshold_decimal_bps, ouroboros_mode)` — method on BulkStoreMixin
- L469: `store_bars_bulk(self, symbol, threshold_decimal_bps, bars, ...)` — method
- L734: `store_bars_batch(self, symbol, threshold_decimal_bps, bars, ...)` — method

**query_operations.py** (6 functions):

- L40: `get_n_bars(self, symbol, threshold_decimal_bps, n_bars, ...)` — method
- L222: `get_latest_bar_timestamp(self, symbol, threshold_decimal_bps, ...)` — method
- L284: `get_ariadne_high_water_mark(self, symbol, threshold_decimal_bps, ...)` — method
- L348: `get_latest_bar_with_trade_id(self, symbol, threshold_decimal_bps, ...)` — method
- L467: `get_bars_overlapping_trade_span(self, symbol, threshold_decimal_bps, new_first_tid, new_last_tid, ...)` — method
- L528: `get_bars_by_timestamp_range(self, symbol, threshold_decimal_bps, start_ts, end_ts, ...)` — method

**maintenance.py** (6 functions):

- L32: `_get_partition_lock(symbol, threshold_decimal_bps)` — module-level
- L46: `_dead_letter_replace_failure(symbol, threshold_decimal_bps, ...)` — module-level
- L101: `deduplicate_bars(self, symbol, threshold_decimal_bps, timeout)` — method. NOTE: symbol and threshold here are both `| None` for the "all" case. Only apply BarKey when both are provided. Change signature to `symbol_or_key: str | BarKey | None = None`.
- L205: `store_bars_replacing(self, symbol, threshold_decimal_bps, bars_df, ...)` — method
- L274: `_store_bars_replacing_inner(self, symbol, threshold_decimal_bps, bars_df, ...)` — method
- L309: `_do_replace(self, symbol, threshold_decimal_bps, bars_df, ...)` — method

**bar_count_api.py** (3 functions):

- L23: `count_bars(self, symbol, threshold_decimal_bps, ...)` — method
- L96: `get_oldest_bar_timestamp(self, symbol, threshold_decimal_bps, ...)` — method
- L144: `get_newest_bar_timestamp(self, symbol, threshold_decimal_bps, ...)` — method

**checkpoint_operations.py** (4 functions):

- L23: `delete_bars(self, symbol, threshold_decimal_bps, start_ts, end_ts, *, ouroboros_mode)` — method
- L65: `save_checkpoint(self, symbol, threshold_decimal_bps, ...)` — method
- L156: `load_checkpoint(self, symbol, threshold_decimal_bps, ...)` — method
- L243: `delete_checkpoint(self, symbol, threshold_decimal_bps, ...)` — method

**cache.py** (1 function):

- L613: `invalidate_open_deviation_bars_by_range(self, symbol, threshold_decimal_bps, start_timestamp_ms, end_timestamp_ms, *, ouroboros_mode)` — method

**write_lock.py** (1 function):

- L81: `cache_write_lock(symbol, threshold_decimal_bps, *, timeout)` — module-level context manager

### Special cases to handle

1. **`deduplicate_bars`**: Both params are `None`-able for the "optimize all" case. Use this pattern:

   ```python
   def deduplicate_bars(self, symbol_or_key: str | BarKey | None = None, threshold_decimal_bps: int | None = None, timeout: int = 600):
       if symbol_or_key is not None and not isinstance(symbol_or_key, str) and not isinstance(symbol_or_key, type(None)):
           # BarKey passed
           from opendeviationbar.types import BarKey
           if isinstance(symbol_or_key, BarKey):
               symbol = symbol_or_key.symbol
               threshold_decimal_bps = symbol_or_key.threshold_decimal_bps
           else:
               symbol = symbol_or_key
       else:
           symbol = symbol_or_key
       # rest unchanged, uses symbol and threshold_decimal_bps
   ```

2. **`_get_partition_lock` and `_dead_letter_replace_failure`**: These are called internally by other functions that will already have destructured the BarKey. Still add BarKey support for direct callers, but keep it simple.

3. **`delete_bars`**: Has `ouroboros_mode` as keyword-only (`*`). When BarKey is passed, `ouroboros_mode` comes from the key. Keep `ouroboros_mode` as keyword-only for backward compat but default it to `None` instead of requiring it, and resolve from BarKey or config.

4. **`invalidate_open_deviation_bars_by_range`**: Same pattern as `delete_bars` — `ouroboros_mode` is keyword-only mandatory. Change to `ouroboros_mode: str | None = None` and resolve from BarKey.

5. **Internal callers within the same module**: When one function calls another (e.g., `delete_bars` calls `invalidate_open_deviation_bars_by_range`), pass the already-resolved individual params, NOT the BarKey. This avoids double-resolution overhead and keeps the diff minimal.

6. **No changes to function BODIES beyond the resolution block**: After destructuring `key.symbol, key.threshold_decimal_bps, key.ouroboros_mode` into local variables, the entire rest of each function body remains identical. This is the key advantage of expand-contract.
   </action>

  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 ruff check python/opendeviationbar/types.py python/opendeviationbar/clickhouse/bulk_operations.py python/opendeviationbar/clickhouse/query_operations.py python/opendeviationbar/clickhouse/maintenance.py python/opendeviationbar/clickhouse/bar_count_api.py python/opendeviationbar/clickhouse/checkpoint_operations.py python/opendeviationbar/clickhouse/cache.py python/opendeviationbar/clickhouse/write_lock.py && echo "ruff OK"</automated>
  </verify>
  <done>All 27 functions accept both BarKey and individual params. ruff check passes. No existing function body logic changed beyond the 3-line resolution block at top.</done>
</task>

<task type="auto">
  <name>Task 3: Run full test suite to confirm backward compatibility</name>
  <files></files>
  <action>
Run the full Python test suite to verify no existing tests break (BARKEY-05). Since these are ClickHouse-connected tests, run with `mise run test-py` which handles the `db:ensure` preflight. If `mise run test-py` is not available locally (no ClickHouse), run the unit tests that don't require ClickHouse:

```bash
cd /Users/terryli/eon/opendeviationbar-py
mise run test-py
```

If any test fails, inspect the failure. The most likely causes:

- A function signature change broke a positional call site (fix: check if any test passes threshold as positional arg to a function that now has `symbol_or_key` as first param — these should still work since `str` is accepted)
- An `ouroboros_mode` that was previously required keyword-only is now optional — this should not break callers since they still can pass it

Also run a quick import smoke test to verify no circular imports:

```bash
uv run --python 3.13 python -c "from opendeviationbar.types import BarKey; from opendeviationbar.clickhouse.cache import OpenDeviationBarCache; print('imports OK')"
```

Also run ruff on the full project to catch any issues:

```bash
uv run --python 3.13 ruff check python/opendeviationbar/
```

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 ruff check python/opendeviationbar/ && uv run --python 3.13 python -c "from opendeviationbar import BarKey; from opendeviationbar.clickhouse.bulk_operations import BulkStoreMixin; from opendeviationbar.clickhouse.query_operations import QueryOperationsMixin; from opendeviationbar.clickhouse.maintenance import MaintenanceMixin; from opendeviationbar.clickhouse.bar_count_api import BarCountMixin; from opendeviationbar.clickhouse.checkpoint_operations import CheckpointOperationsMixin; from opendeviationbar.clickhouse.write_lock import cache_write_lock; print('all imports OK')"</automated>
  </verify>
  <done>All existing tests pass. ruff check clean. All modules import without circular dependency. BarKey is usable from both opendeviationbar and opendeviationbar.types.</done>
</task>

</tasks>

<verification>
1. `uv run --python 3.13 python -c "from opendeviationbar import BarKey; k = BarKey('BTCUSDT', 250); print(k)"` prints `BarKey(symbol='BTCUSDT', threshold_decimal_bps=250, ouroboros_mode='aion')`
2. `uv run --python 3.13 ruff check python/opendeviationbar/` — zero errors
3. `mise run test-py` — all existing tests pass (BARKEY-05)
4. BarKey is frozen (immutable): `k.symbol = 'X'` raises `FrozenInstanceError`
</verification>

<success_criteria>

- BarKey frozen dataclass exists in python/opendeviationbar/types.py
- BarKey re-exported from python/opendeviationbar/**init**.py
- All 27 ClickHouse functions accept BarKey as first argument (expand-contract)
- All 27 ClickHouse functions still accept individual params (backward compat)
- ruff check passes on all modified files
- All existing tests pass without modification
  </success_criteria>

<output>
After completion, create `.planning/phases/39/39-01-SUMMARY.md`
</output>
