# DCR Fixes Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix the CRITICAL and MEDIUM issues found by DCR Wave 1: escape hatch defeating anti-ping-pong, WebSocket leaking OAuth tokens, and active hours default mismatch.

**Architecture:** Three targeted fixes in `usage_monitor.py`: (1) pass `account` to escape hatch `should_swap` call, (2) strip sensitive fields from `usage_poll_updated` broadcast, (3) align active hours defaults with settings schema.

**Tech Stack:** Python, pytest

---

## File Structure

| File | Responsibility |
|------|---------------|
| `jacked/api/usage_monitor.py` | All three fixes are in this file |
| `tests/unit/test_auto_swap.py` | Test for escape hatch + deficit interaction |

---

### Task 1: Fix escape hatch `should_swap` call to preserve deficit suppression

**Files:**
- Modify: `jacked/api/usage_monitor.py:320-329`
- Test: `tests/unit/test_auto_swap.py`

The escape hatch calls `should_swap()` without `account`, `active_start`, `active_end` to test "would a swap have triggered without reset suppression?" But omitting `account` also removes deficit-aware 7d suppression, causing false positives on deficit accounts.

- [ ] **Step 1: Write the failing test**

Add to `tests/unit/test_auto_swap.py` in the `TestShouldSwapDeficitAware` class:

```python
    def test_deficit_suppression_preserved_without_reset_params(self):
        """Removing reset params should NOT also remove deficit suppression.

        This simulates the escape hatch: it strips resets_5h_at/resets_7d_at
        but should still pass account for deficit awareness.
        """
        from datetime import datetime, timezone, timedelta
        import time as _time

        # Account at 85% 7d, behind schedule (deficit ~8%)
        acct = _acct(1, usage_5h=50, usage_7d=85)
        acct["cached_7d_resets_at"] = (
            datetime.now(timezone.utc) + timedelta(hours=12)
        ).isoformat()
        acct["usage_cached_at"] = int(_time.time()) - 60

        # With account but WITHOUT reset params (escape hatch scenario):
        # deficit suppression should still prevent the 7d trigger
        assert should_swap(
            usage_5h=50, usage_7d=85,
            account=acct,
            active_start="07:00", active_end="22:00",
            # No resets_5h_at, no resets_7d_at — simulating escape hatch
        ) is False
```

- [ ] **Step 2: Run test to verify it passes (it should already pass)**

Run: `uv run python -m pytest tests/unit/test_auto_swap.py::TestShouldSwapDeficitAware::test_deficit_suppression_preserved_without_reset_params -v`
Expected: PASS — the `should_swap` function already handles this correctly when `account` is passed. The bug is in the CALLER (usage_monitor.py), not the function itself.

- [ ] **Step 3: Fix the escape hatch caller in usage_monitor.py**

In `jacked/api/usage_monitor.py`, find the escape hatch `should_swap` call at line 320-329:

```python
                would_swap_without_suppress = should_swap(
                    usage_5h=usage_5h,
                    usage_7d=usage_7d,
                    critical_5h=effective_critical,
                    warning_5h=warning_5h,
                    threshold_7d=threshold_7d,
                    burn_rate=br,
                    check_interval_min=check_interval / 60,
                    # No reset params → no suppression
                )
```

Replace with:

```python
                would_swap_without_suppress = should_swap(
                    usage_5h=usage_5h,
                    usage_7d=usage_7d,
                    critical_5h=effective_critical,
                    warning_5h=warning_5h,
                    threshold_7d=threshold_7d,
                    burn_rate=br,
                    check_interval_min=check_interval / 60,
                    # No reset params → no reset suppression
                    # But keep account + active hours for deficit suppression
                    account=active_acct,
                    active_start=active_start,
                    active_end=active_end,
                )
```

- [ ] **Step 4: Run tests**

Run: `uv run python -m pytest tests/unit/test_auto_swap.py -v`
Expected: ALL PASS

- [ ] **Step 5: Commit**

```bash
git add jacked/api/usage_monitor.py tests/unit/test_auto_swap.py
git commit -m "fix: escape hatch should_swap call preserves deficit suppression"
```

---

### Task 2: Strip sensitive fields from `usage_poll_updated` WebSocket broadcast

**Files:**
- Modify: `jacked/api/usage_monitor.py:249-257`

The `usage_poll_updated` broadcast sends the raw `active_acct` dict from `db.list_accounts()` which includes `access_token`, `refresh_token`, `cc_access_token`, `cc_refresh_token`. The REST API strips these via `_account_to_response()` but the WebSocket bypasses that sanitization.

- [ ] **Step 1: Add a sanitization step before the broadcast**

In `jacked/api/usage_monitor.py`, find the `usage_poll_updated` broadcast at lines 249-257:

```python
            _ws = getattr(app.state, "ws_registry", None)
            if _ws and active_acct:
                await _ws.broadcast(
                    "usage_poll_updated",
                    {
                        "account_id": active_acct_id,
                        "account_data": active_acct,
                    },
                )
```

Replace with:

```python
            _ws = getattr(app.state, "ws_registry", None)
            if _ws and active_acct:
                # Strip sensitive fields before broadcasting — the raw DB row
                # includes OAuth tokens that must never reach WebSocket clients.
                _SENSITIVE_FIELDS = {
                    "access_token", "refresh_token",
                    "cc_access_token", "cc_refresh_token",
                }
                safe_acct = {
                    k: v for k, v in active_acct.items()
                    if k not in _SENSITIVE_FIELDS
                }
                await _ws.broadcast(
                    "usage_poll_updated",
                    {
                        "account_id": active_acct_id,
                        "account_data": safe_acct,
                    },
                )
```

- [ ] **Step 2: Run tests**

Run: `uv run python -m pytest tests/ -v`
Expected: ALL PASS

- [ ] **Step 3: Commit**

```bash
git add jacked/api/usage_monitor.py
git commit -m "fix: strip OAuth tokens from usage_poll_updated WebSocket broadcast"
```

---

### Task 3: Align active hours defaults with settings schema

**Files:**
- Modify: `jacked/api/usage_monitor.py:185-186`

The settings schema (`settings_swap.py:44-45`) and UI default to `"06:00"/"23:00"`. But `usage_monitor.py:185-186` reads settings with fallback defaults of `"07:00"/"22:00"`. On a fresh install with no saved settings, the engine uses different hours than what the UI shows.

- [ ] **Step 1: Fix the defaults**

In `jacked/api/usage_monitor.py`, find lines 185-186:

```python
            active_start = _setting_str(db, "window_keeper_active_start", "07:00")
            active_end = _setting_str(db, "window_keeper_active_end", "22:00")
```

Replace with:

```python
            active_start = _setting_str(db, "window_keeper_active_start", "06:00")
            active_end = _setting_str(db, "window_keeper_active_end", "23:00")
```

- [ ] **Step 2: Run tests**

Run: `uv run python -m pytest tests/ -v`
Expected: ALL PASS

- [ ] **Step 3: Commit**

```bash
git add jacked/api/usage_monitor.py
git commit -m "fix: align active hours defaults with settings schema (06:00/23:00)"
```
