---
phase: 18-monitor-validation-gates
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/clickhouse/bulk_operations.py
  - scripts/heartbeat/checks/stathera.py
  - tests/test_mon02_prewrite_gate.py
autonomous: true
requirements: [MON-02, MON-03]

must_haves:
  truths:
    - "Aion symbol bars that cross midnight are NOT filtered by the pre-write gate"
    - "Day-mode symbol bars that cross midnight ARE still filtered by the pre-write gate"
    - "A DataFrame containing mixed Aion and Day symbols correctly filters only Day-mode cross-midnight bars"
    - "Stathera cross-midnight oracle excludes Aion bars from violation count"
  artifacts:
    - path: "python/opendeviationbar/clickhouse/bulk_operations.py"
      provides: "Per-symbol day-mode pre-write gate"
      contains: "day_symbols"
    - path: "scripts/heartbeat/checks/stathera.py"
      provides: "Mode-aware cross-midnight oracle"
      contains: "ouroboros_mode"
    - path: "tests/test_mon02_prewrite_gate.py"
      provides: "Pre-write gate unit tests for both modes"
      min_lines: 40
  key_links:
    - from: "python/opendeviationbar/clickhouse/bulk_operations.py"
      to: "opendeviationbar.symbol_registry"
      via: "get_symbol_entries() for per-symbol mode lookup"
      pattern: "get_symbol_entries"
    - from: "scripts/heartbeat/checks/stathera.py"
      to: "ClickHouse query"
      via: "ouroboros_mode = 'day' filter in cross-midnight oracle"
      pattern: "ouroboros_mode.*day"
---

<objective>
Make the pre-write day-mode gate and Stathera cross-midnight oracle Aion-aware.

Purpose: Without these changes, Aion bars that intentionally span midnight would be (1) silently filtered by the pre-write gate in bulk_operations.py, and (2) falsely reported as cross-midnight violations in the heartbeat Stathera check. Both would cause data loss and alert noise.

Output: Per-symbol mode-aware pre-write gate in both pandas and Polars paths, mode-filtered cross-midnight oracle query, and unit tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-monitor-validation-gates/18-RESEARCH.md

@python/opendeviationbar/clickhouse/bulk_operations.py
@scripts/heartbeat/checks/stathera.py
@python/opendeviationbar/kintsugi/\_helpers.py
</context>

<interfaces>
<!-- Per-symbol mode resolution pattern (from kintsugi/_helpers.py) -->
```python
def _get_mode_for_symbol(symbol: str) -> str:
    from opendeviationbar.symbol_registry import get_symbol_entries
    entries = get_symbol_entries()
    entry = entries.get(symbol)
    if entry is None:
        return "day"
    return entry.ouroboros_mode.value
```

<!-- Current global mode check (from ouroboros.py) -->

```python
def get_operational_ouroboros_mode() -> Literal["day", "aion"]: ...
```

<!-- ClickHouse stathera constants (from stathera_query.py) -->

```python
FULL_TABLE: str  # "opendeviationbar_cache.open_deviation_bars"
```

</interfaces>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Per-symbol pre-write gate + cross-midnight oracle Aion exclusion</name>
  <files>python/opendeviationbar/clickhouse/bulk_operations.py, scripts/heartbeat/checks/stathera.py, tests/test_mon02_prewrite_gate.py</files>
  <read_first>
    - python/opendeviationbar/clickhouse/bulk_operations.py (lines 617-640 pandas path, lines 947-972 Polars path)
    - scripts/heartbeat/checks/stathera.py (lines 272-290 cross-midnight oracle)
    - python/opendeviationbar/kintsugi/_helpers.py (lines 66-77 _get_mode_for_symbol pattern)
    - python/opendeviationbar/ouroboros.py (get_operational_ouroboros_mode)
    - tests/test_heartbeat_yesterday_junction.py (test patterns: _make_client, _make_symbol_entry)
  </read_first>
  <behavior>
    - Test 1 (pandas): DataFrame with BTCUSDT (aion) cross-midnight bars passes through unfiltered
    - Test 2 (pandas): DataFrame with EURUSD (day) cross-midnight bars are filtered out
    - Test 3 (pandas): Mixed DataFrame filters only day-mode cross-midnight bars, keeps aion bars
    - Test 4 (Polars): Same as Test 1 but using store_bars_bulk Polars path
    - Test 5 (Polars): Same as Test 2 but using store_bars_bulk Polars path
    - Test 6: When symbol column is missing, fall back to global mode check (backward compat)
  </behavior>
  <action>
    **MON-02: bulk_operations.py (both pandas and Polars paths)**

    Replace the global `_get_mode() == "day"` check with per-symbol mode resolution:

    1. In `store_bars_batch()` (pandas path, around line 617-639):
       - Remove `from opendeviationbar.ouroboros import get_operational_ouroboros_mode as _get_mode`
       - Replace the `if _get_mode() == "day"` block with per-symbol logic:
         - If `"symbol" in df.columns`: build `day_symbols` set from `get_symbol_entries()` where `entry.ouroboros_mode.value == "day"`, then add `mask_day_mode = df["symbol"].isin(day_symbols)` to the cross-midnight mask
         - If `"symbol" not in df.columns`: fall back to global `get_operational_ouroboros_mode()` check (backward compat)

    2. In `store_bars_bulk()` (Polars path, around line 947-972):
       - Same pattern but using Polars API: `pl.col("symbol").is_in(list(day_symbols))` in the filter expression

    **MON-03: stathera.py cross-midnight oracle (around line 272-290)**

    Add `AND ouroboros_mode = 'day'` to the cross-midnight oracle query:
    ```sql
    SELECT count() AS violations
    FROM {FULL_TABLE} FINAL
    WHERE toDate(open_time_us / 1000000, 'UTC')
       != toDate(close_time_us / 1000000, 'UTC')
      AND ouroboros_mode = 'day'
    ```

    **Tests: tests/test_mon02_prewrite_gate.py**

    Create test file following the mock patterns from test_heartbeat_yesterday_junction.py. Mock `get_symbol_entries()` to return entries with BTCUSDT as aion and EURUSD as day. Mock the ClickHouse client's `insert_df`/`insert_arrow`. Build DataFrames with cross-midnight timestamps (open_time_us in day N, close_time_us in day N+1) and verify:
    - Aion symbol rows survive the gate
    - Day-mode symbol rows are filtered
    - Mixed batches handle both correctly

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_mon02_prewrite_gate.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "day_symbols" python/opendeviationbar/clickhouse/bulk_operations.py
    - grep -q "ouroboros_mode.*day" scripts/heartbeat/checks/stathera.py
    - grep -q "is_in" python/opendeviationbar/clickhouse/bulk_operations.py
    - test -f tests/test_mon02_prewrite_gate.py
  </acceptance_criteria>
  <done>
    - Pre-write gate in both pandas and Polars paths uses per-symbol mode lookup instead of global mode
    - Aion cross-midnight bars pass through; Day-mode cross-midnight bars are filtered
    - Stathera cross-midnight oracle only counts day-mode violations
    - All 6+ test cases pass
  </done>
</task>

</tasks>

<verification>
1. `uv run --python 3.13 pytest tests/test_mon02_prewrite_gate.py -x -v` -- all tests pass
2. `grep -c "get_operational_ouroboros_mode" python/opendeviationbar/clickhouse/bulk_operations.py` should show reduced usage (only in fallback path)
3. `grep "ouroboros_mode" scripts/heartbeat/checks/stathera.py` shows the Aion exclusion filter
</verification>

<success_criteria>

- Pre-write gate correctly handles mixed Aion/Day DataFrames
- Cross-midnight oracle excludes Aion bars
- Tests cover pandas path, Polars path, and mixed-mode batches
  </success_criteria>

<output>
After completion, create `.planning/phases/18-monitor-validation-gates/18-01-SUMMARY.md`
</output>
