# Phase 2.5: Surgical Patches Design

**Goal:** Resolve 4 deferred items from Phase 2 without modifying Protocol interfaces or os.py's pipeline structure.

**Approach:** Surgical patches -- minimal, targeted changes to existing implementations. No new abstractions.

**Scope:**
1. BufferedEmitter thread safety (Lock with fine-grained boundaries)
2. budget_headroom_ratio injection (os.py -> FileStore)
3. Provider separation convention (documentation only)
4. JSONL rotation (4-B: rotation + read both generations)

---

## 1. BufferedEmitter Thread Safety

### Problem

`buffered_emitter.py:28-30` uses `assert threading.current_thread() is threading.main_thread()`.
This crashes when VeronicaOS is called from non-main threads (e.g., web framework workers).

### Design

Replace the assert with `threading.Lock`. Lock protects **internal state only**; subscriber callbacks execute **outside the lock**.

**Lock type:** `threading.Lock` (not RLock). Re-entrant `emit()` from a subscriber callback is safe because callbacks run outside the lock. Infinite recursion is the caller's responsibility.

#### emit()

```python
def emit(self, event_type, payload):
    with self._lock:
        self._buffer.append((event_type, payload))
        targets = list(self._subscribers.items())  # snapshot

    # --- outside lock ---
    local_resets: list[str] = []
    local_removals: list[str] = []
    for name, callback in targets:
        try:
            callback(event_type, payload)
            local_resets.append(name)
        except (SystemExit, KeyboardInterrupt):
            raise  # Rule A: never swallow fatal exceptions
        except Exception:
            # Determine removal inline
            with self._lock:
                count = self._fail_counts.get(name, 0) + 1
                self._fail_counts[name] = count
                if count >= _MAX_CONSECUTIVE_FAILURES:
                    local_removals.append(name)

    # Batch update under single lock acquisition
    with self._lock:
        for name in local_resets:
            if name in self._fail_counts:
                self._fail_counts[name] = 0
        for name in local_removals:
            self._subscribers.pop(name, None)
            self._fail_counts.pop(name, None)
```

#### subscribe() / unsubscribe()

```python
def subscribe(self, name, callback):
    with self._lock:
        self._subscribers[name] = callback
        self._fail_counts[name] = 0

def unsubscribe(self, name):
    with self._lock:
        self._subscribers.pop(name, None)
        self._fail_counts.pop(name, None)
```

#### drain() / snapshot()

```python
def drain(self, n):
    with self._lock:
        result = []
        for _ in range(min(n, len(self._buffer))):
            result.append(self._buffer.popleft())
    return result  # return outside lock

def snapshot(self):
    with self._lock:
        return list(self._buffer)
```

### Invariants / Implementation Notes

**Rule A: Exception classification for unsubscribe.**
- `(SystemExit, KeyboardInterrupt)`: always re-raise. Never swallow fatal signals.
- All other `Exception` subclasses: increment fail count. 3 consecutive failures -> auto-unsubscribe.

**Rule B: Subscriber count assumption.**
- This design assumes a small number of subscribers (< 50). The per-subscriber lock acquisitions in `emit()` are acceptable at this scale.
- If subscriber count grows beyond ~50, refactor to batch all fail_count updates into a single lock acquisition (accumulate resets/increments in local lists, apply under one `with self._lock` at the end).
- Current implementation uses a hybrid: individual lock for failure tracking (needed for accurate count during concurrent emits) + batched lock for resets and removals.

**Re-entrancy policy:** Subscribers may call `emit()` from within their callback without deadlock (callbacks execute outside the lock). However, infinite recursion is the caller's responsibility. This is documented in the class docstring.

### Tests

1. **Concurrent emit**: 10 threads call `emit()` simultaneously. Assert buffer length == 10 and no data corruption.
2. **Re-entrant emit**: Subscriber calls `emit()` inside its callback. Assert no deadlock and both events recorded.
3. **Slow subscriber isolation**: One subscriber sleeps 100ms. Assert other subscribers still receive the event promptly.
4. **Concurrent subscribe/unsubscribe**: One thread emits while another subscribes/unsubscribes. Assert no deadlock or crash.
5. **Fatal exception propagation**: Subscriber raises `KeyboardInterrupt`. Assert it propagates to caller (not swallowed).

### Files Modified

- `src/veronica/buffered_emitter.py` -- Lock refactor
- `tests/test_buffered_emitter.py` -- 5 new thread-safety tests

---

## 2. budget_headroom_ratio Injection

### Problem

`file_store.py:179` hardcodes `budget_headroom_ratio=1.0`. The Planner and Analyzer cannot assess budget pressure.

### Design

Add a `set_budget_context()` method to FileStore (not on Protocol -- this is implementation-specific). `os.py:after_step()` calls it before `commit()`. FileStore reads and clears the context during `commit()`.

#### FileStore changes

```python
class FileStore:
    def __init__(self, ...):
        ...
        self._budget_ceiling: float | None = None
        self._budget_remaining: float | None = None

    def set_budget_context(self, ceiling_usd: float, remaining_usd: float) -> None:
        self._budget_ceiling = ceiling_usd
        self._budget_remaining = remaining_usd

    def commit(self, outcome, analysis, cost, desired, policy, meta):
        # ... existing logic ...

        # Consume budget context (read + clear)
        ceiling = self._budget_ceiling
        remaining = self._budget_remaining
        self._budget_ceiling = None
        self._budget_remaining = None

        if ceiling is not None and remaining is not None and ceiling > _EPS:
            stats.budget_headroom_ratio = remaining / ceiling
        # else: previous value retained (no update)

    def build_history(self, chain_id, limit=50):
        stats = self._chain_stats.get(chain_id, _ChainStats())
        ...
        return HistoryView(
            ...
            budget_headroom_ratio=stats.budget_headroom_ratio,
        )
```

#### _ChainStats changes

Add `budget_headroom_ratio: float = 1.0` to `__slots__`, `to_dict()`, and `from_dict()`.

#### os.py changes

```python
def after_step(self, handle, snapshot):
    ...
    # Before commit: inject budget context if Store supports it
    if hasattr(self._store, "set_budget_context"):
        self._store.set_budget_context(
            ceiling_usd=handle.policy.ceiling_usd,
            remaining_usd=self._request_budget_usd - self._total_spent_usd,
        )

    self._store.commit(outcome, analysis, handle.cost, ...)
    ...
```

The `hasattr` check ensures backward compatibility with MemoryStore and any other StoreProtocol implementation.

### Tests

1. **Basic injection**: `set_budget_context(10.0, 7.0)` -> commit -> `build_history()` returns `headroom=0.7`.
2. **Zero ceiling**: `set_budget_context(0.0, 0.0)` -> headroom stays at previous value (no division by zero).
3. **No context set**: Skip `set_budget_context()` -> `build_history()` returns default 1.0.
4. **Commit clears context**: Call `set_budget_context()`, commit, then commit again without setting -> second commit does not update headroom (cleared after first consume).
5. **Integration with os.py**: `hasattr` path works with FileStore; MemoryStore path (no method) doesn't crash.

### Files Modified

- `src/veronica/file_store.py` -- `set_budget_context()`, commit consume logic, _ChainStats field
- `src/veronica/os.py:200-247` -- `hasattr` + `set_budget_context()` call before commit
- `tests/test_file_store.py` -- 5 new headroom tests

---

## 3. Provider Separation Convention

### Problem

`StepIntent.model` is a flat string like `"gpt-4"`. Same model name from different providers (e.g., Azure OpenAI vs OpenAI) pollutes EMA aggregation.

### Design

Documentation-only change. No source code modification.

Create `docs/conventions.md` with the provider/model naming convention.

### Content

```markdown
# VERONICA Conventions

## Model String Format

`StepIntent.model` uses the format `"{provider}/{model}"`.

### Examples

- `openai/gpt-4`
- `anthropic/claude-sonnet-4-20250514`
- `google/gemini-2.0-flash`
- `azure-openai/gpt-4`
- `bedrock/claude-sonnet-4-20250514`

Provider names: lowercase alphanumeric + hyphens.

### Backward Compatibility

Bare model names (e.g., `"gpt-4"`) are accepted but discouraged.

**Warning:** The model string is the Store's aggregation key for EMA computation.
Mixing formats (e.g., `"gpt-4"` and `"openai/gpt-4"`) contaminates per-model
EMA values. Choose one format and use it consistently within a deployment.
```

### Files Created

- `docs/conventions.md`

---

## 4. JSONL Rotation (4-B)

### Problem

FileStore appends to a single JSONL file per chain indefinitely. `_load_recent_outcomes()` reads all lines (O(total_lines)), degrading over time.

### Design

**Strategy 4-B:** Single-generation rotation. Active file + one rotated file. `build_history()` reads both.

#### Rotation Trigger

`_ChainStats.total_commits` modulo `max_lines` (default 10,000). In-memory counter, persisted in stats JSON. Process restart may cause slight over-accumulation before rotation -- acceptable ("file gets a bit bigger, next rotation catches up").

#### Rotation Sequence (strict order)

```
1. Append JSONL line to active file
2. Flush file handle (f.flush())
3. Update _ChainStats
4. Check: total_commits % max_lines == 0?
   YES ->
     5a. Close active file handle (if any)
     5b. Delete {chain_id}.1.jsonl if exists (Windows rename limitation)
     5c. Rename {chain_id}.jsonl -> {chain_id}.1.jsonl
     5d. On rename failure: log warning, continue (safe side -- Rule D)
```

#### build_history() Read Order

```
1. Read {chain_id}.jsonl (active)
2. If len(outcomes) < limit:
   3. Read {chain_id}.1.jsonl (previous generation)
   4. Prepend to outcomes
5. Return last `limit` entries from combined list
```

This avoids cold-start after rotation: the .1 file provides continuity.

### Invariants / Implementation Notes

**Rule C: Rotate only after successful commit.**
The rotation check happens AFTER the JSONL append succeeds and stats are updated. If the append fails (disk full, permission error), no rotation occurs. The sequence is: write -> flush -> stats update -> rotate check. Never rotate on a failed write.

**Rule D: Rename failure is non-fatal on Windows.**
Windows cannot rename a file if another handle holds it open. The `_rotate()` method:
1. Deletes `{chain_id}.1.jsonl` first (Windows `Path.rename()` cannot overwrite).
2. Wraps the rename in try/except. On failure, logs a warning and returns.
3. Next commit will retry rotation (total_commits will still satisfy the modulo condition next time only if we don't reset the counter -- see below).

To enable retry: rotation resets a separate `_commits_since_rotate` counter (not `total_commits`). `total_commits` is cumulative for stats; `_commits_since_rotate` tracks rotation timing.

#### FileStore.__init__ changes

```python
def __init__(self, data_dir, flush_interval=10, max_lines=10_000):
    ...
    self._max_lines = max_lines
```

#### _ChainStats changes

Add `commits_since_rotate: int = 0` to slots/to_dict/from_dict.

#### commit() rotation logic

```python
stats.commits_since_rotate += 1

if stats.commits_since_rotate >= self._max_lines:
    if self._rotate(chain_id):
        stats.commits_since_rotate = 0
    # else: rotation failed, will retry next commit
```

#### _rotate()

```python
def _rotate(self, chain_id: str) -> bool:
    active = self._data_dir / f"{chain_id}.jsonl"
    rotated = self._data_dir / f"{chain_id}.1.jsonl"
    try:
        if rotated.exists():
            rotated.unlink()  # Windows: must delete before rename
        if active.exists():
            active.rename(rotated)
        return True
    except OSError:
        logger.warning("JSONL rotation failed for %s, will retry", chain_id)
        return False
```

#### _load_recent_outcomes() changes

```python
def _load_recent_outcomes(self, chain_id, limit):
    active_path = self._data_dir / f"{chain_id}.jsonl"
    rotated_path = self._data_dir / f"{chain_id}.1.jsonl"

    outcomes = self._read_jsonl(active_path)
    if len(outcomes) < limit and rotated_path.exists():
        older = self._read_jsonl(rotated_path)
        outcomes = older + outcomes  # older first, then current

    return outcomes[-limit:]  # last N
```

### Tests

1. **Rotation trigger**: `max_lines=5`, commit 6 times -> `.1.jsonl` exists, active has 1 line.
2. **Post-rotation history**: After rotation, `build_history()` returns entries from both files. No cold-start gap.
3. **Double rotation**: Commit 12 times with `max_lines=5` -> `.1.jsonl` contains generation 2 data (generation 1 overwritten). Active has 2 lines.
4. **Rotation failure resilience**: Mock `Path.rename()` to raise `OSError` -> warning logged, next commit retries.
5. **Stats persistence**: After rotation, `close()` + reload -> `commits_since_rotate` is 0.

### Files Modified

- `src/veronica/file_store.py` -- rotation logic, _load_recent_outcomes dual-read, _ChainStats field
- `tests/test_file_store.py` -- 5 new rotation tests

---

## Summary of All Changes

| File | Section | Change |
|------|---------|--------|
| `src/veronica/buffered_emitter.py` | 1 | Lock refactor (assert -> Lock, fine-grained boundaries) |
| `tests/test_buffered_emitter.py` | 1 | 5 new thread-safety tests |
| `src/veronica/file_store.py` | 2, 4 | budget context injection + JSONL rotation |
| `tests/test_file_store.py` | 2, 4 | 10 new tests (5 headroom + 5 rotation) |
| `src/veronica/os.py` | 2 | `set_budget_context()` call before commit (3 lines) |
| `docs/conventions.md` | 3 | Provider/model naming convention |

**Protocol changes:** None.
**os.py pipeline structure:** Unchanged (1 `hasattr` + 1 method call added to `after_step()`).
