# Phase 17: Kintsugi Aion Repair - Research

**Researched:** 2026-03-27
**Domain:** Kintsugi gap reconciliation for Aion (continuous) ouroboros mode
**Confidence:** HIGH

## Summary

Phase 17 adapts kintsugi's gap repair pipeline to handle Aion-mode symbols. Currently, kintsugi operates exclusively in day-mode: it discovers Stathera anomalies, identifies affected UTC days, deletes all bars for those days, and reprocesses from scratch using Vision/Parquet/REST data. This "day-recompute" strategy is fundamentally tied to the day-mode invariant (each UTC day is independently reprocessable from midnight).

For Aion symbols, there are no UTC day boundaries -- bars span continuously across midnight. The repair strategy must change from "recompute entire UTC day(s)" to "reprocess from the gap's anchor TID forward." The writer ownership boundary must also change from UTC-day-based (sidecar owns today, kintsugi owns yesterday+) to TID-based (sidecar owns TIDs beyond its committed_floors).

The critical insight from code analysis: `get_operational_ouroboros_mode()` currently returns a **global** mode from Settings, but kintsugi processes multiple symbols with potentially different modes. All 6 call sites in kintsugi (`discovery.py`, `repair.py`, `verify.py`, `telemetry.py`) use this global function. Phase 17 must switch relevant call sites to per-symbol mode lookup via `SymbolEntry.ouroboros_mode` from the symbol registry.

**Primary recommendation:** Make kintsugi per-symbol mode-aware by looking up `ouroboros_mode` from the symbol registry for each shard's symbol. For Day-mode symbols, existing day-recompute is unchanged. For Aion symbols, repair from anchor TID forward with no today-guard. Niffler/Charon dead-letter paths are already mode-agnostic (they read symbol/threshold from filename, replay bars via `store_bars_batch` -- no mode branching needed).

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions

None -- all implementation choices are at Claude's discretion (infrastructure phase).

### Claude's Discretion

All implementation choices -- pure infrastructure phase. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.

### Deferred Ideas (OUT OF SCOPE)

None.

</user_constraints>

<phase_requirements>

## Phase Requirements

| ID      | Description                                                                         | Research Support                                                                                                                                                                                              |
| ------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| KINT-01 | Aion repair reprocesses from gap point forward to next known bar, not by day        | `_repair()` in `repair.py` must branch: Day mode keeps day-recompute; Aion mode fetches trades from `anchor_last_tid` to `resume_first_tid` and reprocesses. No UTC-day slicing for Aion.                     |
| KINT-02 | Writer boundary for Aion is TID-based; UTC-day today-guard skipped for Aion symbols | Today-guard in `_repair()` (lines 197-210) checks `gap_end_date >= today_utc`. For Aion symbols, replace with TID-based check against sidecar's `committed_floors`. Or skip entirely (sidecar dedup handles). |
| KINT-03 | Niffler/Charon dead-letter recovery fires identically for Aion -- no mode branching | `dead_letter.py:replay_dead_letters()` is already mode-agnostic: reads Parquet, extracts symbol/threshold from filename, calls `store_bars_batch`. No changes needed.                                         |

</phase_requirements>

## Standard Stack

No new libraries required. This phase modifies existing code paths only.

### Core (existing)

| Library            | Version   | Purpose                          | Why Standard                                  |
| ------------------ | --------- | -------------------------------- | --------------------------------------------- |
| symbol_registry.py | n/a       | Per-symbol ouroboros_mode lookup | SSoT for symbol metadata (REG-01/02 Phase 13) |
| kintsugi package   | n/a       | Gap reconciliation engine        | Existing 8-file modular package               |
| stathera_query.py  | n/a       | Shared Stathera SQL builder      | Used by discovery, heartbeat, detect_gaps     |
| clickhouse-connect | installed | ClickHouse queries and writes    | Already in use                                |
| polars             | installed | Bar DataFrames for repair data   | Already in use                                |

## Architecture Patterns

### Current Flow (Day-mode only)

```
discover_shards()
  -> _discover_shards_stathera()  // uses global ouroboros_mode
  -> For each shard:
       _repair()
         -> Identify UTC days spanning the gap
         -> Today-guard: clamp gap_end_date to yesterday
         -> For each day in batch:
              _repair_under_lock()
                -> Parquet / Vision / REST day-recompute
                -> store_bars_batch(overlap_strategy="replace")
       verify_repair()
         -> Stathera check with global ouroboros_mode
```

### Required Flow (Per-symbol mode-aware)

```
discover_shards()
  -> _discover_shards_stathera()  // per-symbol ouroboros_mode from registry
  -> For each shard:
       Look up shard.symbol -> SymbolEntry.ouroboros_mode
       if DAY:
         _repair() [existing day-recompute, unchanged]
       if AION:
         _repair_aion()  [new: TID-range reprocess]
           -> Fetch trades from anchor_last_tid to resume_first_tid
           -> Process with single continuous processor (no day splits)
           -> store_bars_batch(overlap_strategy="replace")
           -> No today-guard (TID-based ownership instead)
       verify_repair()
         -> Stathera check with per-symbol ouroboros_mode
```

### Key Architectural Changes

#### 1. Per-symbol mode lookup replaces global mode

Currently 6 call sites use `get_operational_ouroboros_mode()` which returns a global setting. These must be changed to per-symbol lookup:

| File           | Current Call                       | New Approach                                                     |
| -------------- | ---------------------------------- | ---------------------------------------------------------------- |
| `discovery.py` | `get_operational_ouroboros_mode()` | Lookup `SymbolEntry.ouroboros_mode` per (symbol, threshold) pair |
| `repair.py`    | `get_operational_ouroboros_mode()` | Lookup from `shard.symbol` via registry                          |
| `verify.py`    | `get_operational_ouroboros_mode()` | Lookup from `shard.symbol` via registry                          |
| `telemetry.py` | `get_operational_ouroboros_mode()` | Lookup from `result.shard.symbol` via registry                   |

**Pattern:** Add a helper like `_get_mode_for_symbol(symbol: str) -> str` in `_helpers.py` that caches the registry lookup.

#### 2. Aion repair strategy: TID-range reprocess

For Day mode, repair means "delete entire UTC day, reprocess all trades for that day." For Aion mode, repair means "delete bars in the gap's TID range, fetch trades from anchor TID to resume TID, reprocess."

Key differences:

| Aspect              | Day Mode                              | Aion Mode                                              |
| ------------------- | ------------------------------------- | ------------------------------------------------------ |
| Scope               | Entire UTC day(s)                     | TID range: anchor_last_tid to resume_first_tid         |
| Processor lifecycle | Fresh per day (reset at midnight)     | Single continuous processor for gap range              |
| Data source         | Parquet/Vision/REST for full day      | Parquet/REST from anchor TID (Vision is daily)         |
| Orphan marking      | Last bar per day = orphan             | No orphan bars (continuous)                            |
| Batch days          | `cfg.batch_days` limits days per pass | Not applicable (single continuous range)               |
| Delete scope        | DELETE WHERE date = repair_date       | DELETE WHERE first_agg_trade_id BETWEEN anchor..resume |

**Data source for Aion repair:** Vision archives are daily -- they CAN still be used for Aion if the gap happens to span complete UTC days. But the primary path should be Parquet tick cache (which stores daily files regardless of bar mode) or REST fromId pagination from anchor TID. Vision is a fallback when Parquet is unavailable.

#### 3. Today-guard replacement for Aion

Current today-guard (`repair.py` lines 197-210):

```python
today_utc = datetime.now(tz=UTC).date()
if gap_end_date >= today_utc:
    # Clamp or defer
```

For Aion symbols, this guard must be replaced with a TID-based check. The sidecar's `committed_floors` dict tracks the highest TID for each (symbol, threshold) pair. Kintsugi should not repair gaps whose `resume_first_tid` is beyond the sidecar's committed floor.

**Practical approach:** Since kintsugi and sidecar run as separate processes, reading committed_floors requires either:

- (a) A ClickHouse query: `SELECT max(last_agg_trade_id) FROM ... WHERE symbol=X AND threshold=Y` -- this is the highest committed bar, equivalent to committed_floors.
- (b) A shared state file or the existing checkpoint mechanism.

**Recommendation:** Use approach (a). Query CH for the max committed `last_agg_trade_id` per (symbol, threshold). If the shard's `resume_first_tid` exceeds this max, the gap is in the sidecar's live domain -- defer. This is simpler than cross-process state sharing and consistent with kintsugi's existing CH-centric approach.

**Alternative (simpler):** For the v7.0 initial implementation, Aion symbols can skip the today-guard entirely. The sidecar's committed_floors dedup in `live_engine.rs` already prevents duplicate bars from being emitted. If kintsugi repairs a gap that the sidecar also covers, the ReplacingMergeTree dedup (ORDER BY first_agg_trade_id) handles it. This is safe because:

1. Kintsugi writes with `overlap_strategy="replace"` (DELETE + INSERT)
2. The sidecar's `committed_floors` filter prevents re-emission of bars already written
3. ReplacingMergeTree dedup is the L2 safety net

#### 4. Dead-letter paths (KINT-03): No changes needed

`dead_letter.py:replay_dead_letters()` is completely mode-agnostic:

- Reads Parquet files from `_DEAD_LETTER_DIR`
- Extracts `symbol` and `threshold` from filename
- Calls `store_bars_batch()` with `overlap_strategy="replace"`
- No ouroboros_mode reference anywhere in the file

The only mode-related code is a `ValueError` catch for day-mode gate violations (line 58-69). For Aion bars, this gate won't fire because cross-midnight bars are valid in Aion mode. **However**, the pre-write gate in `bulk_operations.py` may need to be conditional for Aion symbols (this is MON-02 in Phase 18, not Phase 17).

**Verification:** The dead-letter replay calls `store_bars_batch()` which calls the pre-write gate in `bulk_operations.py`. If the pre-write gate currently rejects cross-midnight bars unconditionally, Aion dead-letters could be rejected. This is a Phase 18 concern (MON-02), but Phase 17 should document this dependency.

### Anti-Patterns to Avoid

- **Anti-pattern: Mode-specific dead-letter paths.** Success criterion #3 explicitly requires NO mode-specific branching in dead-letter paths. Niffler/Charon must remain mode-agnostic.
- **Anti-pattern: Global ouroboros_mode for mixed-mode fleet.** Using `get_operational_ouroboros_mode()` would apply one mode to all symbols. Must use per-symbol lookup.
- **Anti-pattern: Day-recompute for Aion gaps.** Recomputing "the entire UTC day" is meaningless for Aion bars that span midnight. Must reprocess by TID range.

## Don't Hand-Roll

| Problem                  | Don't Build             | Use Instead                                                | Why                                                  |
| ------------------------ | ----------------------- | ---------------------------------------------------------- | ---------------------------------------------------- |
| Per-symbol mode lookup   | Manual TOML parsing     | `symbol_registry.get_symbol_entries()[sym].ouroboros_mode` | Registry is already SSoT, cached via @lru_cache      |
| TID-range trade fetching | Custom REST pagination  | Existing `backfill._process_gap_fallback_starttime()`      | Already handles REST pagination; adapt for TID range |
| Parquet tick reading     | Custom Parquet I/O      | `TickStorage.read_ticks()`                                 | Already handles daily partitioned Parquet files      |
| Bar processing           | Python bar construction | `OpenDeviationBarProcessor` (Rust via PyO3)                | Existing processor handles all bar logic             |

## Common Pitfalls

### Pitfall 1: Pre-write gate rejects Aion bars as cross-midnight

**What goes wrong:** `bulk_operations.py` has a day-mode pre-write gate (L2 defense layer) that filters bars spanning UTC midnight. Aion bars naturally span midnight, so they'd be rejected.
**Why it happens:** The gate was designed for Day-mode only and doesn't know about Aion.
**How to avoid:** This is Phase 18 (MON-02). Phase 17 must ensure Aion bars written by kintsugi include the correct `ouroboros_mode` column value ("aion") so the gate can differentiate. Document the dependency.
**Warning signs:** 0 bars written for Aion repairs, "cross-midnight" warnings in logs.

### Pitfall 2: Stathera query uses wrong ouroboros_mode

**What goes wrong:** Discovery query filters by `ouroboros_mode = {ouroboros:String}`. If a global "day" mode is passed but the bars in CH are stored as "aion", zero shards are discovered.
**Why it happens:** `_discover_shards_stathera()` calls `get_operational_ouroboros_mode()` once and uses it for all pairs.
**How to avoid:** Look up per-symbol ouroboros_mode from registry when building the discovery query for each (symbol, threshold) pair.
**Warning signs:** Kintsugi reports "no shards found" even when Aion gaps exist.

### Pitfall 3: Vision archives are daily but Aion gaps may span partial days

**What goes wrong:** A gap from TID 1000 to TID 2000 might span 3 hours in the middle of a day. Using Vision would download the entire day's trades (millions), process them all, then delete/replace only the gap region.
**Why it happens:** Vision archives are daily files with no TID-based filtering.
**How to avoid:** For Aion repairs, prefer Parquet tick cache (can read specific TID ranges from daily files) or REST fromId pagination (fetches exact TID range). Vision is a fallback.
**Warning signs:** Excessive trade fetching for small Aion gaps.

### Pitfall 4: Orphan bar marking in Aion repairs

**What goes wrong:** Day-mode repairs mark the last bar per day as `is_orphan=True`. In Aion mode, there are no day boundaries, so no orphan bars should be created during repair.
**Why it happens:** The orphan marking logic is hardcoded in `_try_parquet_day_replace()` and `_try_vision_day_replace()`.
**How to avoid:** Aion repair path must NOT mark last bar as orphan. The last bar in a TID-range repair connects seamlessly to the next known bar.
**Warning signs:** Unexpected `is_orphan=True` bars in the middle of Aion data.

### Pitfall 5: Genesis shard handling for Aion

**What goes wrong:** `_discover_genesis_shards()` creates shards covering "the day immediately before the current oldest bar." For Aion symbols, genesis backfill should still work day-by-day (trading data comes in daily files), but bars should be processed continuously across days.
**Why it happens:** Genesis shards are a special case that works differently from interior gap repair.
**How to avoid:** Genesis shards for Aion can continue using the day-by-day expansion strategy (it's about fetching data, not bar construction). The processor for genesis should NOT reset at day boundaries. Process multiple days' trades in sequence through a single processor instance.
**Warning signs:** Orphan bars at every midnight in Aion genesis backfill.

## Code Examples

### Per-symbol mode lookup helper

```python
# In kintsugi/_helpers.py
def _get_mode_for_symbol(symbol: str) -> str:
    """Get ouroboros_mode for a symbol from the registry."""
    from opendeviationbar.symbol_registry import get_symbol_entries
    entries = get_symbol_entries()
    entry = entries.get(symbol)
    if entry is None:
        return "day"  # safe default
    return entry.ouroboros_mode.value
```

### Aion repair: TID-range reprocess (sketch)

```python
def _repair_aion(
    shard: Shard,
    *,
    include_microstructure: bool = True,
) -> int:
    """Repair an Aion shard by reprocessing trades in the TID range."""
    from opendeviationbar.processors.core import OpenDeviationBarProcessor
    from opendeviationbar.storage.parquet import TickStorage

    storage = TickStorage()

    # Convert TID range to approximate date range for Parquet lookup
    lo_us = min(shard.gap_start_ms, shard.gap_end_ms)
    hi_us = max(shard.gap_start_ms, shard.gap_end_ms)
    start_date = datetime.fromtimestamp(lo_us / 1_000_000, tz=UTC).date()
    end_date = datetime.fromtimestamp(hi_us / 1_000_000, tz=UTC).date()

    # Load trades from Parquet for the date range, filter to TID range
    # ... (load daily Parquet files, filter agg_trade_id in range)

    # Single continuous processor (no day reset)
    processor = OpenDeviationBarProcessor(
        threshold_decimal_bps=shard.threshold_decimal_bps,
        symbol=shard.symbol,
    )

    # Process trades, collect bars
    # ... (stream through processor, NO orphan marking)

    # Delete existing bars in TID range, write new bars
    cache = _get_worker_cache()
    written = cache.store_bars_batch(
        symbol=shard.symbol,
        threshold_decimal_bps=shard.threshold_decimal_bps,
        bars=bars_df,
        version=f"{__version__}+kintsugi-aion",
        overlap_strategy="replace",
        _skip_lock=True,
    )
    return written
```

### Discovery with per-symbol mode

```python
# In discovery.py: _discover_shards_stathera()
# Change from:
#   ouroboros_mode = get_operational_ouroboros_mode()
# To per-pair lookup:
for sym, thr in valid_pairs:
    sym_mode = _get_mode_for_symbol(sym)
    result = cache.client.query(
        f"""...""",
        parameters={
            "symbol": sym,
            "threshold": thr,
            "ouroboros": sym_mode,  # per-symbol, not global
        },
    )
```

## State of the Art

| Old Approach                          | Current Approach                        | When Changed | Impact                                    |
| ------------------------------------- | --------------------------------------- | ------------ | ----------------------------------------- |
| Global ouroboros_mode for all symbols | Per-symbol ouroboros_mode from registry | v7.0 (now)   | Mixed Day+Aion fleet in same kintsugi run |
| Day-recompute for all gaps            | TID-range reprocess for Aion gaps       | v7.0 (now)   | Correct repair for continuous bars        |
| UTC-day today-guard                   | TID-based or skip guard for Aion        | v7.0 (now)   | Kintsugi can repair recent Aion gaps      |

## Open Questions

1. **Pre-write gate interaction (Phase 18 dependency)**
   - What we know: `bulk_operations.py` has a cross-midnight filter. Aion bars naturally cross midnight.
   - What's unclear: Whether the filter checks `ouroboros_mode` column or applies unconditionally.
   - Recommendation: Phase 17 should ensure `ouroboros_mode` column is correctly set on all kintsugi-written bars. Phase 18 (MON-02) makes the gate conditional.

2. **Aion genesis backfill processor state**
   - What we know: Genesis backfill expands one day at a time. For Aion, the processor should NOT reset between days.
   - What's unclear: Whether `_repair()` creates a fresh processor per day (it does for Day mode).
   - Recommendation: For Aion genesis, either (a) process multi-day trades through one processor, or (b) use checkpoint/restore between days. Option (a) is simpler for v7.0.

3. **TID-based ownership query performance**
   - What we know: Querying `max(last_agg_trade_id)` per (symbol, threshold) requires a scan.
   - What's unclear: Query cost on 42M+ bars.
   - Recommendation: The FINAL modifier with partition pruning (symbol, threshold_decimal_bps) makes this fast. Partition has ~64 entries, each pair is one partition.

## Validation Architecture

### Test Framework

| Property           | Value                                                          |
| ------------------ | -------------------------------------------------------------- |
| Framework          | pytest (Python tests)                                          |
| Config file        | `pyproject.toml` [tool.pytest.ini_options]                     |
| Quick run command  | `uv run --python 3.13 pytest tests/test_kintsugi_repair.py -x` |
| Full suite command | `mise run test-py`                                             |

### Phase Requirements -> Test Map

| Req ID  | Behavior                                    | Test Type | Automated Command                                                                            | File Exists? |
| ------- | ------------------------------------------- | --------- | -------------------------------------------------------------------------------------------- | ------------ |
| KINT-01 | Aion repair by TID range, not day recompute | unit      | `uv run --python 3.13 pytest tests/test_kintsugi_aion.py::test_aion_repair_tid_range -x`     | Wave 0       |
| KINT-02 | No today-guard for Aion symbols             | unit      | `uv run --python 3.13 pytest tests/test_kintsugi_aion.py::test_aion_no_today_guard -x`       | Wave 0       |
| KINT-03 | Dead-letter mode-agnostic                   | unit      | `uv run --python 3.13 pytest tests/test_kintsugi_aion.py::test_dead_letter_mode_agnostic -x` | Wave 0       |

### Sampling Rate

- **Per task commit:** `uv run --python 3.13 pytest tests/test_kintsugi_aion.py -x`
- **Per wave merge:** `mise run test-py`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tests/test_kintsugi_aion.py` -- covers KINT-01, KINT-02, KINT-03
- [ ] Fixtures: mock SymbolEntry with ouroboros_mode=aion, mock ClickHouse, mock TickStorage

## Sources

### Primary (HIGH confidence)

- `python/opendeviationbar/kintsugi/` -- Full source review of all 8 modules
- `python/opendeviationbar/ouroboros.py` -- OuroborosMode enum, get_operational_ouroboros_mode()
- `python/opendeviationbar/symbol_registry.py` -- SymbolEntry.ouroboros_mode field
- `python/opendeviationbar/clickhouse/stathera_query.py` -- Shared Stathera SQL builder
- `.planning/phases/16-sidecar-aion-startup/16-RESEARCH.md` -- Phase 16 Aion startup research

### Secondary (MEDIUM confidence)

- `.planning/REQUIREMENTS.md` -- KINT-01/02/03 requirement definitions
- Phase 14 CONTEXT.md -- Gap fields and conditional reset decisions

## Metadata

**Confidence breakdown:**

- Standard stack: HIGH - no new libraries, all existing code
- Architecture: HIGH - full source review of all kintsugi modules completed
- Pitfalls: HIGH - identified from actual code analysis (pre-write gate, orphan marking, Stathera query filtering)

**Research date:** 2026-03-27
**Valid until:** 2026-04-27 (stable codebase, no external dependencies)
