---
phase: 01-atomic-replace-heal-scoping
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/sidecar.py
  - tests/test_sync_flush_atomic_replace.py
autonomous: true
requirements: [ATOM-01, ATOM-02]
must_haves:
  truths:
    - "_sync_flush_rest_bars() calls store_bars_replacing() instead of _write_batch_with_retry()"
    - "Bar dicts are converted to Polars DataFrame before store_bars_replacing()"
    - "Fallback to _write_batch_with_retry() on store_bars_replacing() exception"
    - "After sync_flush, a Stathera audit on today's bars shows zero overlaps"
  artifacts:
    - path: "python/opendeviationbar/sidecar.py"
      provides: "Atomic replace in _sync_flush_rest_bars()"
      contains: "store_bars_replacing"
    - path: "tests/test_sync_flush_atomic_replace.py"
      provides: "Tests verifying atomic replace behavior"
      min_lines: 40
  key_links:
    - from: "python/opendeviationbar/sidecar.py"
      to: "python/opendeviationbar/clickhouse/maintenance.py"
      via: "store_bars_replacing() call"
      pattern: "cache\\.store_bars_replacing"
---

<objective>
Replace `_write_batch_with_retry()` in `_sync_flush_rest_bars()` with `store_bars_replacing()` so that REST bars from fill_from_rest atomically replace any stale bars in the same trade-ID range. This eliminates overlaps from prior sidecar sessions.

Purpose: ATOM-01 and ATOM-02 -- every sidecar restart produces exactly one clean bar chain per (symbol, threshold) for today.
Output: Modified `_sync_flush_rest_bars()` using atomic replace, plus tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-atomic-replace-heal-scoping/01-CONTEXT.md
@python/opendeviationbar/sidecar.py
@python/opendeviationbar/clickhouse/maintenance.py

<interfaces>
<!-- Key types and contracts the executor needs. -->

From python/opendeviationbar/clickhouse/maintenance.py (lines 266-305):

```python
def store_bars_replacing(
    self,
    symbol: str,
    threshold_decimal_bps: int,
    bars_df: object,  # Must be polars.DataFrame
    ouroboros_mode: str | None = None,
    version: str | None = None,
    _skip_lock: bool = False,
) -> int:
    """Idempotent replace: delete overlapping bars then insert new ones.
    Returns number of rows inserted."""
```

The lightweight DELETE SQL in `_do_replace()` uses:

```sql
DELETE FROM opendeviationbar_cache.open_deviation_bars
WHERE symbol = '{symbol}'
  AND threshold_decimal_bps = {threshold}
  AND ouroboros_mode = '{ouroboros}'
  AND first_agg_trade_id <= {new_last}
  AND last_agg_trade_id >= {new_first}
  AND first_agg_trade_id > 0
SETTINGS lightweight_mutation_projection_mode = 'drop'
```

From python/opendeviationbar/sidecar.py (line 156):

```python
# Existing pattern for dict->Polars conversion:
bars_df = pl.DataFrame(bar_dicts)
```

From python/opendeviationbar/sidecar.py (lines 159-161):

```python
from opendeviationbar import __version__
source_version = f"{__version__}+sidecar"
```

</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Replace _write_batch_with_retry with store_bars_replacing in _sync_flush_rest_bars</name>
  <files>python/opendeviationbar/sidecar.py, tests/test_sync_flush_atomic_replace.py</files>
  <read_first>
    - python/opendeviationbar/sidecar.py (lines 762-824: _sync_flush_rest_bars)
    - python/opendeviationbar/sidecar.py (lines 138-170: _write_bars_batch_to_clickhouse for pattern)
    - python/opendeviationbar/clickhouse/maintenance.py (lines 266-370: store_bars_replacing signature + inner)
  </read_first>
  <behavior>
    - Test 1: sync_flush with bars calls cache.store_bars_replacing() with Polars DataFrame, correct symbol/threshold, _skip_lock=False, version containing "+sidecar"
    - Test 2: sync_flush with empty ring buffer returns 0 and does NOT call store_bars_replacing
    - Test 3: when store_bars_replacing raises an exception, falls back to _write_batch_with_retry and returns the bar count (non-fatal)
    - Test 4: cross-midnight bars are still filtered out before reaching store_bars_replacing
  </behavior>
  <action>
    **Step 1: Create test file** `tests/test_sync_flush_atomic_replace.py`

    Mock `engine.next_bar()` to return bar dicts with `_symbol`, `_threshold`, `open_time_ms`, `close_time_ms`, `first_agg_trade_id`, `last_agg_trade_id` keys (return None after N bars to signal empty).

    Mock `OpenDeviationBarCache` and patch it into the function. Assert `store_bars_replacing()` is called with:
    - `symbol="BTCUSDT"`, `threshold_decimal_bps=250`
    - `bars_df` is a `polars.DataFrame` (not a list of dicts)
    - `_skip_lock=False`
    - `version` contains `"+sidecar"`

    For the fallback test: make `store_bars_replacing` raise `RuntimeError`, assert `_write_batch_with_retry` is called instead.

    **Step 2: Modify `_sync_flush_rest_bars()`** (sidecar.py lines 814-823)

    Replace the write loop at lines 814-823:
    ```python
    # OLD:
    written = 0
    for (symbol, threshold), bars in batches.items():
        if _write_batch_with_retry(symbol, threshold, bars):
            written += len(bars)
    ```

    With atomic replace:
    ```python
    from opendeviationbar.clickhouse.cache import OpenDeviationBarCache
    from opendeviationbar import __version__

    source_version = f"{__version__}+sidecar"
    written = 0

    with OpenDeviationBarCache() as cache:
        for (symbol, threshold), bars in batches.items():
            try:
                bars_df = pl.DataFrame(bars)
                inserted = cache.store_bars_replacing(
                    symbol=symbol,
                    threshold_decimal_bps=threshold,
                    bars_df=bars_df,
                    version=source_version,
                    _skip_lock=False,
                )
                written += inserted
            except Exception:
                logger.warning(
                    "sync_flush: store_bars_replacing failed for %s@%d, "
                    "falling back to _write_batch_with_retry (non-fatal)",
                    symbol, threshold, exc_info=True,
                )
                if _write_batch_with_retry(symbol, threshold, bars):
                    written += len(bars)
    ```

    Keep the existing drain loop (lines 783-811) completely unchanged. Only replace the write loop.

    **Important**: The `pl` import already exists at the top of sidecar.py (line ~13). Verify before adding a duplicate.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_sync_flush_atomic_replace.py -x -v 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n "store_bars_replacing" python/opendeviationbar/sidecar.py` shows the call inside `_sync_flush_rest_bars`
    - `grep -n "_write_batch_with_retry" python/opendeviationbar/sidecar.py` still appears (fallback path) but NOT as the primary write in sync_flush
    - `grep -n "_skip_lock=False" python/opendeviationbar/sidecar.py` shows the lock param in sync_flush
    - `grep -n "pl.DataFrame" python/opendeviationbar/sidecar.py` shows dict-to-Polars conversion in sync_flush
    - All tests in test_sync_flush_atomic_replace.py pass
  </acceptance_criteria>
  <done>
    _sync_flush_rest_bars() uses store_bars_replacing() as primary write path with _skip_lock=False. Fallback to _write_batch_with_retry on exception. Bar dicts converted to Polars DataFrame. Tests verify happy path, empty buffer, fallback, and cross-midnight filtering.
  </done>
</task>

</tasks>

<verification>
```bash
# 1. Verify store_bars_replacing is called in sync_flush
grep -n "store_bars_replacing" python/opendeviationbar/sidecar.py

# 2. Verify \_skip_lock=False (sync_flush acquires its own lock)

grep -n "\_skip_lock=False" python/opendeviationbar/sidecar.py

# 3. Verify fallback path still exists

grep -A2 "falling back to \_write_batch_with_retry" python/opendeviationbar/sidecar.py

# 4. Run tests

cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_sync_flush_atomic_replace.py -x -v

# 5. Ensure existing tests still pass

cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/ -x --timeout=120 -q 2>&1 | tail -5

```
</verification>

<success_criteria>
- `_sync_flush_rest_bars()` atomically replaces stale bars via `store_bars_replacing()` (ATOM-01)
- Bar dicts converted to Polars DataFrame before the call
- `_skip_lock=False` ensures Redis write lock is acquired
- Fallback to `_write_batch_with_retry()` on any exception (non-fatal)
- After sync_flush completes, the trade-ID range contains only the new bars (ATOM-02 mechanical guarantee from store_bars_replacing's DELETE+INSERT)
- All new and existing tests pass
</success_criteria>

<output>
After completion, create `.planning/phases/01-atomic-replace-heal-scoping/01-01-SUMMARY.md`
</output>
```
