---
phase: 26-exchange-gap-classification-dismiss
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - python/opendeviationbar/kintsugi/discovery.py
  - python/opendeviationbar/kintsugi/repair.py
  - python/opendeviationbar/kintsugi/telemetry.py
  - tests/test_kintsugi_exchange_gap.py
autonomous: true
requirements: [EXGAP-01, EXGAP-02, DISMISS-01, DISMISS-02]

must_haves:
  truths:
    - "When _repair_aion() produces 0 bars for a TID range and ALL Parquet dates are checksum-verified, the shard is logged as result='exchange_gap' in kintsugi_log"
    - "When Parquet is NOT checksum-verified but produces 0 bars, the shard is logged as result='clean' (normal 30-day dismiss)"
    - "Kintsugi shard selection permanently skips shards with prior result='exchange_gap' entries (no 30-day expiry)"
    - "Exchange gap classification is auditable via kintsugi_log"
  artifacts:
    - path: "python/opendeviationbar/kintsugi/discovery.py"
      provides: "KintsugiResult.is_exchange_gap field + permanent dismiss in _get_dismissed_set()"
      contains: "is_exchange_gap"
    - path: "python/opendeviationbar/kintsugi/repair.py"
      provides: "Exchange gap classification in _repair_aion()"
      contains: "is_exchange_gap"
    - path: "python/opendeviationbar/kintsugi/telemetry.py"
      provides: "result='exchange_gap' in _log_repair()"
      contains: "exchange_gap"
    - path: "tests/test_kintsugi_exchange_gap.py"
      provides: "Unit tests for exchange gap classification and dismiss"
  key_links:
    - from: "python/opendeviationbar/kintsugi/repair.py"
      to: "python/opendeviationbar/checksum_registry.py"
      via: "ChecksumRegistry.has_verified() call in _repair_aion()"
      pattern: "registry\\.has_verified"
    - from: "python/opendeviationbar/kintsugi/telemetry.py"
      to: "kintsugi_log table"
      via: "_log_repair() INSERT with result='exchange_gap'"
      pattern: "exchange_gap"
    - from: "python/opendeviationbar/kintsugi/discovery.py"
      to: "kintsugi_log table"
      via: "_get_dismissed_set() SELECT WHERE result='exchange_gap'"
      pattern: "exchange_gap"
---

<objective>
Kintsugi permanently classifies and dismisses exchange-level TID gaps so they are never retried.

Purpose: Eliminate 146h of wasted repair cycles where kintsugi repeatedly attempts to heal 96K gaps that are permanent exchange-level TID discontinuities (Binance never issued those trade IDs). When `_repair_aion()` processes checksum-verified Parquet and finds 0 trades in a TID range, the gap is classified as an exchange gap and permanently dismissed.

Output: Modified kintsugi classification logic (3 files) + unit tests proving exchange gap classification, checksum gating, and permanent dismissal.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-exchange-gap-classification-dismiss/26-RESEARCH.md

@python/opendeviationbar/kintsugi/discovery.py
@python/opendeviationbar/kintsugi/repair.py
@python/opendeviationbar/kintsugi/telemetry.py
@python/opendeviationbar/checksum_registry.py

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

From python/opendeviationbar/kintsugi/discovery.py:

```python
@dataclass
class KintsugiResult:
    shard: Shard
    healed: bool
    bars_written: int
    duration_seconds: float
    had_anchor_tid: bool
    ouroboros_splits: int
    error: str | None = None
    source: str | None = None
    # ADD: is_exchange_gap: bool = False
```

From python/opendeviationbar/kintsugi/\_helpers.py:

```python
_thread_local = threading.local()
# Pattern: _thread_local.last_source = "parquet" (set in _repair_aion, read in repair_shard)
# Same pattern for: _thread_local.is_exchange_gap = True
```

From python/opendeviationbar/checksum_registry.py:

```python
class ChecksumRegistry:
    def has_verified(self, symbol: str, date: str) -> bool:
        # date format: "YYYY-MM-DD" (ISO string)
```

From python/opendeviationbar/kintsugi/telemetry.py:

```python
def _log_repair(result: object) -> None:
    # Current result computation (line 128-133):
    "result": (
        "healed" if result.healed
        else "clean" if not result.error
        else "failed"
    ),
```

From python/opendeviationbar/kintsugi/discovery.py:

```python
def _get_dismissed_set() -> set[tuple[str, int, int]]:
    # Returns (symbol, threshold_decimal_bps, gap_start_ms) tuples
    # Current: SELECT WHERE result='clean' AND bars_written=0 AND timestamp > now() - 30 DAY
```

</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add exchange gap classification to kintsugi (3 files + tests)</name>
  <files>
    python/opendeviationbar/kintsugi/discovery.py
    python/opendeviationbar/kintsugi/repair.py
    python/opendeviationbar/kintsugi/telemetry.py
    tests/test_kintsugi_exchange_gap.py
  </files>
  <read_first>
    python/opendeviationbar/kintsugi/discovery.py
    python/opendeviationbar/kintsugi/repair.py
    python/opendeviationbar/kintsugi/telemetry.py
    python/opendeviationbar/kintsugi/_helpers.py
    python/opendeviationbar/checksum_registry.py
    tests/test_kintsugi_repair.py
  </read_first>
  <behavior>
    - Test 1 (EXGAP-01): When _repair_aion() processes checksum-verified Parquet and TID filter returns 0 trades, repair_shard() returns KintsugiResult with is_exchange_gap=True
    - Test 2 (EXGAP-02): When _repair_aion() processes Parquet that is NOT checksum-verified and TID filter returns 0 trades, repair_shard() returns KintsugiResult with is_exchange_gap=False (normal clean result)
    - Test 3 (EXGAP-02 multi-day): When shard spans 3 dates and only 2 are checksum-verified, result is NOT exchange_gap (all dates must be verified)
    - Test 4 (DISMISS-02): When _log_repair() receives a KintsugiResult with is_exchange_gap=True, the INSERT uses result='exchange_gap' (not 'clean')
    - Test 5 (DISMISS-01): _get_dismissed_set() returns exchange_gap shards with no time window (permanent) plus clean shards with 30-day window
  </behavior>
  <action>
    **Step 1: Write tests first** in `tests/test_kintsugi_exchange_gap.py`. Mock ChecksumRegistry.has_verified(), TickStorage, and ClickHouse client. Follow patterns from tests/test_kintsugi_repair.py for mock setup.

    **Step 2: Modify KintsugiResult** in `discovery.py`:
    - Add `is_exchange_gap: bool = False` field to the dataclass (after `source` field)

    **Step 3: Modify _repair_aion()** in `repair.py`:
    - At the top of the function (before any processing), add `_thread_local.is_exchange_gap = False` to reset the flag
    - At the `if trades_df.is_empty(): return 0` point (line 437-438), BEFORE the return:
      - Import ChecksumRegistry
      - Instantiate registry and check `has_verified(shard.symbol, date.isoformat())` for ALL dates in [start_date, end_date]
      - If ALL dates verified AND parquet_available: set `_thread_local.is_exchange_gap = True` and log info message with symbol, threshold, TID range, and number of verified dates
      - If NOT all verified: leave flag as False (normal 0-bar clean result)

    **Step 4: Modify repair_shard()** in `repair.py`:
    - After reading `source = getattr(_thread_local, "last_source", None)` (line 98), also read `is_exchange_gap = getattr(_thread_local, "is_exchange_gap", False)`
    - Pass `is_exchange_gap=is_exchange_gap` to the returned KintsugiResult (line 118-127)
    - Update the log message (line 107) to show "EXCHANGE_GAP" instead of "NO_BARS" when is_exchange_gap is True

    **Step 5: Modify _log_repair()** in `telemetry.py`:
    - Update the result computation (lines 128-133) to check `is_exchange_gap` first:
      ```python
      "result": (
          "exchange_gap"
          if getattr(result, "is_exchange_gap", False)
          else "healed"
          if result.healed
          else "clean"
          if not result.error
          else "failed"
      ),
      ```

    **Step 6: Modify _get_dismissed_set()** in `discovery.py`:
    - After the existing clean/30-day query, add a SECOND query for permanent exchange gap dismissal:
      ```sql
      SELECT DISTINCT symbol, threshold_decimal_bps, gap_start_ms
      FROM opendeviationbar_cache.kintsugi_log
      WHERE result = 'exchange_gap'
      ```
    - Union the two result sets before returning
    - Update the docstring to mention exchange gap permanent dismissal

    **IMPORTANT**: Use `gap_start_ms` column name (not `gap_start_us`) -- follow existing INSERT code, NOT schema.sql (per RESEARCH.md pitfall 4). Accept 90-day TTL re-classification (per RESEARCH.md open question 1 -- cost is negligible).

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_kintsugi_exchange_gap.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "is_exchange_gap" python/opendeviationbar/kintsugi/discovery.py (KintsugiResult field)
    - grep -q "is_exchange_gap" python/opendeviationbar/kintsugi/repair.py (classification logic)
    - grep -q "exchange_gap" python/opendeviationbar/kintsugi/telemetry.py (result value)
    - grep -q "exchange_gap" python/opendeviationbar/kintsugi/discovery.py (_get_dismissed_set query)
    - grep -q "has_verified" python/opendeviationbar/kintsugi/repair.py (ChecksumRegistry check)
    - grep -q "test_exchange_gap" tests/test_kintsugi_exchange_gap.py (tests exist)
    - grep -q "test_unverified_parquet" tests/test_kintsugi_exchange_gap.py (negative test)
    - grep -q "test_permanent_dismiss" tests/test_kintsugi_exchange_gap.py (dismiss test)
  </acceptance_criteria>
  <done>
    - _repair_aion() classifies 0-trade TID ranges as exchange_gap when ALL Parquet dates are checksum-verified
    - Unverified Parquet produces normal "clean" result (30-day dismiss, not permanent)
    - Multi-day shards require ALL dates verified (partial = not exchange_gap)
    - _log_repair() writes result='exchange_gap' to kintsugi_log
    - _get_dismissed_set() permanently skips exchange_gap shards (no time window)
    - All 5 tests pass
  </done>
</task>

<task type="auto">
  <name>Task 2: Verify existing kintsugi tests still pass</name>
  <files>
    tests/test_kintsugi_repair.py
    tests/test_kintsugi_ordering.py
    tests/test_kintsugi_parquet_trust.py
  </files>
  <read_first>
    tests/test_kintsugi_repair.py
  </read_first>
  <action>
    Run the full existing kintsugi test suite to verify the changes from Task 1 don't break existing behavior. The KintsugiResult dataclass change (adding is_exchange_gap with default=False) should be backward compatible, but verify.

    If any test fails due to the new field, fix the test to account for the new default value. Do NOT change production code to accommodate old tests -- the default=False ensures backward compatibility.

  </action>
  <verify>
    <automated>cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_kintsugi_repair.py tests/test_kintsugi_ordering.py tests/test_kintsugi_parquet_trust.py tests/test_kintsugi_exchange_gap.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - All existing kintsugi tests pass alongside new exchange gap tests
  </acceptance_criteria>
  <done>
    - test_kintsugi_repair.py passes (existing repair tests unbroken)
    - test_kintsugi_ordering.py passes (shard ordering unbroken)
    - test_kintsugi_parquet_trust.py passes (Parquet trust gate unbroken)
    - test_kintsugi_exchange_gap.py passes (new exchange gap tests)
  </done>
</task>

</tasks>

<verification>
All kintsugi tests pass:
```bash
cd /Users/terryli/eon/opendeviationbar-py && uv run --python 3.13 pytest tests/test_kintsugi_*.py -x -v
```

Grep verification:

```bash
grep -q "is_exchange_gap" python/opendeviationbar/kintsugi/discovery.py && echo "PASS: KintsugiResult field"
grep -q "has_verified" python/opendeviationbar/kintsugi/repair.py && echo "PASS: ChecksumRegistry check"
grep -q "exchange_gap" python/opendeviationbar/kintsugi/telemetry.py && echo "PASS: telemetry result value"
grep "exchange_gap" python/opendeviationbar/kintsugi/discovery.py | grep -q "SELECT" && echo "PASS: dismiss query"
```

</verification>

<success_criteria>

1. KintsugiResult has is_exchange_gap field (EXGAP-01)
2. \_repair_aion() checks ChecksumRegistry for ALL dates before classifying as exchange gap (EXGAP-02)
3. \_log_repair() writes result='exchange_gap' to kintsugi_log (DISMISS-02)
4. \_get_dismissed_set() permanently skips exchange_gap shards (DISMISS-01)
5. All existing kintsugi tests pass unchanged
6. New test_kintsugi_exchange_gap.py covers all 4 requirements
   </success_criteria>

<output>
After completion, create `.planning/phases/26-exchange-gap-classification-dismiss/26-01-SUMMARY.md`
</output>
