# Track: Data-Oriented Error Handling (Fleury Pattern)

**Status:** Active (spec approved 2026-06-06)
**Initialized:** 2026-06-06
**Owner:** Tier 2 Tech Lead
**Priority:** High (foundational; unlocks incremental migration of the remaining `src/` in future tracks)

---

## 1. Overview

This track introduces a new project convention — **Data-Oriented Error Handling** — based on Ryan Fleury's "The Easiest Way To Handle Errors Is To Not Have Them" framework. The convention is codified in a new `conductor/code_styleguides/error_handling.md` reference, surfaced in `product-guidelines.md` and `workflow.md`, and applied to three high-value subsystems: `src/mcp_client.py`, `src/ai_client.py`, and `src/rag_engine.py` (~150 refactor sites).

The patterns applied: **Result dataclasses** with side-channel error lists instead of `Optional[T]` / exception-based control flow; **nil-sentinel dataclasses** instead of `None`; **zero-initialized fields** via `@dataclass` defaults; **fail-early** validation pushed to shallow stack frames; **AND-over-OR** return types (data + errors as parallel fields, not a sum type). These collapse the bifurcated codepaths that `if x is None` / `try/except` create, in the spirit of Fleury's argument that "errors are just cases."

A new **public `Result`-based API** (`ai_client.send_result()`) is introduced for new code; the existing `ai_client.send()` is **marked `@deprecated`** (warning emitted at runtime) so callers can migrate incrementally. The actual removal of the deprecated public API is **deferred to a separate follow-up track** (see §13.1) — this track only marks it deprecated and documents the migration path.

## 2. Goals (Priority Order)

| Priority | Goal | Rationale |
|---|---|---|
| **A (foundational)** | New `conductor/code_styleguides/error_handling.md` documenting the 5 patterns with Python mappings. | Establishes the convention as a first-class project standard. Future plans reference this file; new code follows it; the next comprehensive sweep uses it. |
| **A (foundational)** | New `src/result_types.py` with `ErrorInfo` dataclass and `Result[T]` dataclass (generic over data only; errors are `list[ErrorInfo]`). | Provides the canonical building blocks. Re-used across the 3 refactored files and by future migrations. |
| **A (primary value)** | `src/mcp_client.py` refactored: the `(p, err)` tuple returns + `if err or p is None: return err` pattern (~30 sites) and the `assert p is not None` chain (~30+ sites) become nil-sentinel `Path` + `Result` returns with side-channel errors. | Clearest, most-contained refactor target. The MCP tool layer is the "boundary" between the AI and the filesystem; errors here should be data, not exceptions, so the model can react. |
| **A (primary value)** | `src/ai_client.py` refactored: `ProviderError` exception becomes `ErrorInfo` dataclass; internal `_send_<vendor>()` functions return `Result[str, ErrorInfo]`; SDK-exception catches become conversions to `ErrorInfo` (caught at the boundary, not propagated). | The provider layer is the highest-stakes refactor. Catches SDK exceptions at the boundary, converts to data, and lets the rest of the code work with a flat control flow. |
| **A (primary value)** | `src/rag_engine.py` refactored: `RAGEngine._init_vector_store`, `_validate_collection_dim`, `is_empty`, `add_documents` return `Result` with side-channel errors instead of raising `ImportError` / `ValueError`. | The RAG engine has its own ad-hoc error class hierarchy that mirrors the patterns Fleury criticizes. Bringing it into the convention aligns it with the new vendor layer. |
| **B (architectural)** | Existing public `ai_client.send()` is marked `@deprecated` with a runtime warning directing callers to `ai_client.send_result()`. | The public API is preserved (no breaking change) but signals the migration intent. The deprecation message includes a TODO reference to the follow-up track. |
| **B (architectural)** | New public `ai_client.send_result()` returns `Result[str, ErrorInfo]`. The new vendor layer (Qwen/Llama/Grok from the prior track) calls `_send_<vendor>_result()` internally and `send_result()` is the public entry point. | New code uses the new API. Old code keeps working via the deprecated `send()`. |
| **C (documentation)** | `conductor/product-guidelines.md` gets a new "Data-Oriented Error Handling" section summarizing the principles (referencing the code styleguide for details). | The convention is visible in the project-level guidance. |
| **C (documentation)** | `conductor/workflow.md` gets a note in the Code Style section linking to the new styleguide. | The convention is visible in the workflow so all future plans reference it. |
| **C (documentation)** | `docs/guide_*.md` updates: `guide_mcp_client.md` and `guide_ai_client.md` show the new patterns; the next refactor of `guide_rag.md` (or its creation if missing) does the same. | Guides stay in sync with the implementation. |
| **D (forward-looking)** | A new follow-up track "Public API Result Migration" is **planned in this spec's §13.1** (not executed) so it's clear what work remains. | Future plans have a known destination. |

### 2.1 Non-Goals (this track)

- **Not** migrating the remaining `src/` files (`app_controller.py`, `models.py`, `project_manager.py`, `commands.py`, etc.). These are explicitly out of scope; the convention is established so future tracks can migrate them one at a time.
- **Not** removing the public `ai_client.send()`. Only `@deprecated` markers are added. Removal is in a follow-up track.
- **Not** changing the `multi_agent_conductor.py` MMA worker interface or the `app_controller.py` orchestrator interface. They continue to call the public `send()` (which still works) and migrate later.
- **Not** introducing a generic `Result[T, E]` (with `E` as the error type). The Result is generic only over the success data; errors are always `list[ErrorInfo]`. Rationale: per Fleury, errors are a side-channel — they should accumulate, not be a single tagged value. This also avoids Python's `Union[T, E]` complexity.
- **Not** introducing async-aware error propagation. Async / asyncio patterns are out of scope; the refactored code stays synchronous.
- **Not** changing how `logging` works. Errors flow as data in `Result`; logging is the caller's choice (most callers will log via the existing comms_log_callback).

## 3. Architecture

### 3.1 The 5 Patterns + Python Mappings

| # | Fleury pattern | Python mapping | Code location |
|---|---|---|---|
| 1 | **Nil struct pointer** (read-only sentinel) | `@dataclass(frozen=True) class Nil: pass`; module-level `NIL = Nil()` singleton. Frozen prevents runtime mutation; convention prevents writes. | `src/result_types.py:NilPath`, `NilRAGState`, etc. |
| 2 | **Zero-initialization** | `@dataclass` with field defaults. `field(default_factory=list)` for mutables. | Used throughout `Result` and the refactored files. |
| 3 | **Fail early** | Same principle: validation at the entry point; assert or early return. No `goto defer`, but `try/finally` is similar. | Applied to MCP `_resolve_and_check`, RAG `_init_*`, provider `_ensure_*_client`. |
| 4 | **AND over OR (Result struct with side-channel errors)** | `@dataclass(frozen=True) class Result: data: T; errors: list[ErrorInfo]`. Caller: `r = fn(); if r.errors: handle(); else: use(r.data)`. Empty errors list = success. | `src/result_types.py:Result`; used by all 3 refactored files. |
| 5 | **Error info as side-channel** | Per-context error list in the Result struct. The list accumulates all errors encountered, not just the first one. Simpler than C's `errno` (which is single-slot); richer than just raising one exception. | `src/result_types.py:ErrorInfo`; populated by error-classification helpers. |

#### 3.1.1 3rd-Party Validation (independent corroboration)

The "errors are data, not control flow" thesis is independently supported by two other practitioners in the data-oriented / C-style community:

- **Timothy Lottes (@NOTimothyLottes), 2026-06-07** — [X thread]. "Error codes, many APIs get these so wrong. For example aliasing the same code with multiple meaning so the user has zero idea what actually went wrong and what needs fixing." Lottes's pattern: a force-no-inline `ERROR[__line__]: _code_` exit point where the exit code IS the source line number. Errors are zero-cost at init time; "all my error checks are init time (low cost) and only fail just results in this common Err() with printed {line, code} exit path." This track's `Result` dataclass is the Python analog: an `ErrorInfo` with a `source` field and an optional `location: int` (future enhancement) carries the same diagnostic information Lottes's exit code does.

  **Lottes's anti-pattern warning, applied to `ErrorKind`:** "aliasing the same code with multiple meaning" — each `ErrorKind` value has exactly one meaning. Adding a new kind for a new failure mode is preferred over overloading an existing one. The 11 enum values (`NETWORK`, `AUTH`, `QUOTA`, `RATE_LIMIT`, `BALANCE`, `PERMISSION`, `NOT_FOUND`, `INVALID_INPUT`, `NOT_READY`, `UNKNOWN`, `CONFIG`, `INTERNAL`) are the canonical set; if a new failure mode doesn't fit, add a new value, don't overload `UNKNOWN`.

- **Valigo (@valigotech), "Exceptions are horrifying", 2026-06-07** — YouTube, 14 min. Exceptions "mess with control flow in very weird ways"; the caller can no longer read top-to-bottom and predict what happens. TypeScript's failure to express "this throws" is what motivated the Effect library (a Rust-style `Result<T, E>` port). "Modern languages without legacy baggage move away from exceptions — Rust, Jai, Zig, Odin." JavaScript's worst abuse: throwing a `Promise` for Suspense. "Every time you open a website, you see like six different spinners all over the place."

  **Valigo's anti-pattern warning, applied to this codebase:** `ErrorInfo` is a value, never a thrown object. Do not raise it; do not yield it from a generator; do not pass it as a side-effect return; do not use it as a `Promise` rejection value. It is a data value, period. The Hook API's `/api/ask` Remote Confirmation Protocol (a long-running challenge/response) is conceptually similar to Suspense but is **not** an exception mechanism — it returns a JSON object with a `request_id` and a status, not a thrown value. Future code that adds new cross-thread communication patterns must not smuggle exception-like control flow under the guise of a "request."

### 3.2 Module Layout

```
conductor/
  code_styleguides/
    error_handling.md          # NEW: the canonical reference (5 patterns, Python mappings, examples)
  product-guidelines.md        # MODIFIED: new "Data-Oriented Error Handling" section
  workflow.md                  # MODIFIED: note in Code Style section referencing the new styleguide
  tracks.md                    # MODIFIED: register this track; add the public_api_migration_20260606 placeholder

docs/
  guide_mcp_client.md          # MODIFIED: new patterns (if doc exists; otherwise created in follow-up)
  guide_ai_client.md           # MODIFIED: new patterns, deprecation note, Result API
  guide_rag.md                 # MODIFIED: new patterns (if doc exists)

src/
  result_types.py              # NEW: ErrorInfo, Result[T], NilPath, NilRAGState
  mcp_client.py                # MODIFIED: ~60 sites refactored
  ai_client.py                 # MODIFIED: ProviderError → ErrorInfo; _send_* returns Result; send() deprecated; send_result() added
  rag_engine.py                # MODIFIED: ~20 sites refactored

tests/
  test_result_types.py         # NEW: Result + ErrorInfo + nil-sentinel tests
  test_mcp_client_paths.py     # NEW: verify MCP path resolution returns Result
  test_ai_client_result.py     # NEW: verify _send_* return Result, send_result() public API, deprecation warning
  test_rag_engine_result.py    # NEW: verify RAG methods return Result
  test_deprecation_warnings.py # NEW: verify send() emits DeprecationWarning
```

### 3.3 The `Result[T]` and `ErrorInfo` Data Model

```python
from dataclasses import dataclass, field
from typing import Generic, TypeVar
from enum import Enum

T = TypeVar("T")

class ErrorKind(str, Enum):
 NETWORK = "network"
 AUTH = "auth"
 QUOTA = "quota"
 RATE_LIMIT = "rate_limit"
 BALANCE = "balance"
 PERMISSION = "permission"
 NOT_FOUND = "not_found"
 INVALID_INPUT = "invalid_input"
 NOT_READY = "not_ready"
 UNKNOWN = "unknown"
 CONFIG = "config"
 INTERNAL = "internal"
 # Added 2026-06-08 per nagent_review Pitfall #4 (provider history divergence).
 # The Application edits the entry's content (e.g., user fixes a typo in an AI
 # response, or branches at a midpoint via guide_discussions.md §"Per-Entry
 # Operations" A1+A4) but the ai_client._<provider>_history (the bytes
 # actually replayed to the LLM) still contains the original. This is
 # silent corruption, not a thrown error. The PROVIDER_HISTORY_DIVERGED_FROM_UI
 # kind makes the divergence *detectable* and *reportable* so the follow-up
 # public_api_migration_20260606 track can collapse the two history layers
 # (see §12.1).
 PROVIDER_HISTORY_DIVERGED_FROM_UI = "provider_history_diverged_from_ui"

@dataclass(frozen=True)
class ErrorInfo:
 kind: ErrorKind
 message: str
 source: str = "" # which subsystem produced it (e.g. "mcp.read_file", "ai_client.gemini")
 original: BaseException | None = None
 def ui_message(self) -> str:
 src = f"[{self.source}] " if self.source else ""
 return f"{src}{self.kind.value}: {self.message}"

@dataclass(frozen=True)
class Result(Generic[T]):
 data: T
 errors: list[ErrorInfo] = field(default_factory=list)
 @property
 def ok(self) -> bool:
 return not self.errors
 def with_error(self, err: ErrorInfo) -> "Result[T]":
 return Result(data=self.data, errors=[*self.errors, err])
 def with_errors(self, new_errors: list[ErrorInfo]) -> "Result[T]":
 return Result(data=self.data, errors=[*self.errors, *new_errors])
 def with_data(self, new_data: T) -> "Result[T]":
 return Result(data=new_data, errors=list(self.errors))
```

**Design notes:**
- `Result` is generic over `T` (the success data type) but **not** over `E` (the error type). Per Fleury: errors are a side-channel list, not a tagged sum. This also avoids `Union[T, E]` complexity.
- `data: T` is the happy-path result. The success case is `Result(data=X, errors=[])`. The failure case is `Result(data=zero_value, errors=[err1, err2])`.
- `errors` is a `list[ErrorInfo]`, not a single error, so partial failures can be reported (e.g., "5 of 10 files failed; here are the 5 errors").
- `Result` is `frozen=True` (no mutation); use `with_error` / `with_data` to produce modified copies.
- `NilPath` is a `@dataclass(frozen=True)` singleton: `NIL_PATH = NilPath()`. Same for `NilRAGState` etc.

### 3.4 Nil-Sentinel Pattern

The nil sentinel is a `@dataclass(frozen=True)` with all-default values. Module-level singleton. Used when a function "would return None" in the old code; in the new code, it returns the nil sentinel of the right type.

```python
@dataclass(frozen=True)
class NilPath:
 exists: bool = False
 read_text: str = ""
 errors: list[ErrorInfo] = field(default_factory=list)

NIL_PATH = NilPath()
```

`NIL_PATH` is the "empty Path" — it has all default values, can be safely read from (the `read_text` is `""`, no file I/O), and `errors` accumulates any deferred errors. Callers that need a real `pathlib.Path` for filesystem operations can check `if isinstance(result.data, NilPath): handle()` — but most callers just need the read text, and `NIL_PATH.read_text == ""` is fine for the AI model's purposes.

For the MCP client, the `(p, err)` tuple returns are replaced with `Result[Path]`:
- Old: `def _resolve_and_check(path: str) -> tuple[Path | None, str]`
- New: `def _resolve_and_check(path: str) -> Result[Path]` where `Path` is the real `pathlib.Path` on success or `NilPath()` on failure (the `data` field can be a `Path` or `NilPath`; the consumer checks `result.data.__class__` or relies on the duck-typed `read_text` field)

This is the same idea as Fleury's nil struct pointer: callers don't need to `if p is None:` check; they can call `p.read_text` and get `""` on the nil path.

### 3.5 Deprecation Strategy for the Public `send()` API

The public `ai_client.send()` is preserved (existing callers don't break) but marked deprecated:

```python
import warnings
from typing_extensions import deprecated

@deprecated("Use ai_client.send_result() instead. Will be removed in the public_api_migration_20260606 track. See conductor/tracks/data_oriented_error_handling_20260606/spec.md for the migration path.")
def send(...) -> str:
 warnings.warn(
 "ai_client.send() is deprecated; use ai_client.send_result() instead. "
 "The deprecated function will be removed once callers migrate. "
 "See conductor/tracks/data_oriented_error_handling_20260606/spec.md §13.1.",
 DeprecationWarning,
 stacklevel=2,
 )
 return _extract_text(_send_*_result(...))
```

`@deprecated` is the `typing_extensions` backport (works on Python 3.11+; this project requires 3.11+). The decorator:
- Emits a `DeprecationWarning` at the first call (cached after that to avoid log spam).
- Updates type hints in IDEs and type checkers (mypy, pyright) to show the deprecation.
- The `@deprecated` call is a no-op for the runtime; only the warning + type-checker effect.

The new public API:

```python
def send_result(...) -> Result[str]:
 """The Result-based public API. Returns Result[str, ErrorInfo] with text in .data and errors in .errors."""
 # Acquire _send_lock, route to provider, return Result
 ...
```

The `send_result()` function does the same routing as `send()` but returns `Result` instead of unwrapping it. The internal `_send_<vendor>_result()` functions are called from `send_result()`. The deprecated `send()` is a thin wrapper:

```python
@deprecated(...)
def send(...) -> str:
 result = send_result(...)
 if not result.ok:
 _append_comms("WARN", "deprecated_send_with_errors", [e.ui_message() for e in result.errors])
 return result.data
 return result.data
```

This way, the deprecated `send()` keeps working (returning the text even if there were errors, matching today's behavior), and the comms log gets a warning entry so users can see that the old API is being used with errors.

## 4. Per-File Refactor Designs

### 4.1 `src/mcp_client.py`

**Current pattern (the "sum type as tuple"):**
```python
def _resolve_and_check(path: str) -> tuple[Path | None, str]:
 p, err = _resolve_path(path)
 if err: return None, err
 if not _is_in_allowed_base(p): return None, "ERROR: ..."
 if p.exists() and not p.is_file(): return None, "ERROR: ..."
 return p, ""

def read_file(path: str) -> str:
 p, err = _resolve_and_check(path)
 if err or p is None:
 return err
 if not p.exists(): return f"ERROR: file not found: {path}"
 ...
```

**Refactored pattern (Result + nil sentinel):**
```python
def _resolve_and_check(path: str) -> Result[Path]:
 """Returns Result[Path]. On success, .data is a pathlib.Path. On failure, .data is NilPath() and .errors is populated."""
 try:
 p = _resolve_path(path)
 except _ResolutionError as e:
 return Result(data=NilPath(), errors=[ErrorInfo(kind=ErrorKind.INVALID_INPUT, message=str(e), source="mcp._resolve_and_check")])
 if not _is_in_allowed_base(p):
 return Result(data=NilPath(), errors=[ErrorInfo(kind=ErrorKind.PERMISSION, message=f"path '{path}' not in allowed base", source="mcp._resolve_and_check")])
 return Result(data=p)

def read_file(path: str) -> Result[str]:
 """Returns Result[str]. On success, .data is the file's text. On failure, .data is '' and .errors is populated."""
 resolved = _resolve_and_check(path)
 if not resolved.ok:
 return Result(data="", errors=resolved.errors)
 p = resolved.data
 if not p.exists():
 return Result(data="", errors=[ErrorInfo(kind=ErrorKind.NOT_FOUND, message=f"file not found: {path}", source="mcp.read_file")])
 if not p.is_file():
 return Result(data="", errors=[ErrorInfo(kind=ErrorKind.INVALID_INPUT, message=f"not a file: {path}", source="mcp.read_file")])
 try:
 content = p.read_text(encoding="utf-8")
 return Result(data=content)
 except Exception as e:
 return Result(data="", errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=str(e), source="mcp.read_file", original=e)])
```

**Key changes:**
- `_resolve_and_check` returns `Result[Path]` (or `Result[Path | NilPath]` for type clarity). The MCP layer never returns `None` or raises for the resolution step.
- `read_file` and the other tool functions return `Result[str]`. The caller (`mcp_client.async_dispatch` or the tool-dispatch internals) extracts the text or formats the error.
- The 30+ `assert p is not None` checks (lines 304-794) become "trust the Result and use `p.read_text`" — the Path is never None in the Result; it's either a real Path or `NilPath` (with a `read_text` field that's `""`).
- Internal exceptions (`OSError`, `PermissionError`, etc.) are caught at the boundary and converted to `ErrorInfo` — they don't propagate as Python exceptions.

### 4.2 `src/ai_client.py`

**Current pattern (the `ProviderError` exception):**
```python
class ProviderError(Exception):
 kind: str
 provider: str
 original: Exception
 def ui_message(self) -> str: ...

def _send_gemini(...) -> str:
 try:
 resp = genai_client.models.generate_content(...)
 ...
 except Exception as exc:
 raise _classify_gemini_error(exc) from exc
```

**Refactored pattern (ErrorInfo + Result):**
```python
def _classify_gemini_error(exc: Exception, source: str) -> ErrorInfo:
 if isinstance(exc, genai_types.RateLimitError):
 return ErrorInfo(kind=ErrorKind.RATE_LIMIT, message=str(exc), source=source, original=exc)
 if isinstance(exc, genai_types.PermissionDeniedError):
 return ErrorInfo(kind=ErrorKind.AUTH, message=str(exc), source=source, original=exc)
 ...
 return ErrorInfo(kind=ErrorKind.UNKNOWN, message=str(exc), source=source, original=exc)

def _send_gemini_result(...) -> Result[str]:
 try:
 resp = genai_client.models.generate_content(...)
 ...
 return Result(data=text)
 except Exception as exc:
 return Result(data="", errors=[_classify_gemini_error(exc, source="ai_client.gemini")])
```

**Key changes:**
- `ProviderError` exception class becomes `ErrorInfo` dataclass (a value, not a control-flow primitive).
- `_classify_<vendor>_error()` functions return `ErrorInfo` instead of raising `ProviderError`.
- `_send_<vendor>()` becomes `_send_<vendor>_result()` returning `Result[str]`. SDK exceptions are caught at the boundary and converted to `ErrorInfo` (caught at the boundary, not propagated).
- The public `send()` is preserved (marked `@deprecated`) for backward compat; it calls `send_result()` and unwraps.
- The new public `send_result()` returns `Result[str]`.

**Migration note (for the follow-up track):**
- The MMA worker interface in `multi_agent_conductor.py` calls `ai_client.send()`. Migration: call `ai_client.send_result()` and check `.ok` and `.errors`.
- The orchestrator in `app_controller.py` calls `ai_client.send()`. Migration: same.
- ~50+ test files call `ai_client.send()` or directly call `_send_<vendor>()`. Migration: most tests use the public `send()`; only `_send_*()` direct tests need to update.

### 4.3 `src/rag_engine.py`

**Current pattern (raises + ad-hoc error strings):**
```python
def _init_vector_store(self):
 vs_config = self.config.vector_store
 if vs_config.provider == 'chroma':
 db_path = os.path.abspath(...)
 os.makedirs(db_path, exist_ok=True)
 chroma_module = _get_chromadb()
 if chroma_module is None:
 raise ImportError("chromadb is not installed")
 chromadb, Settings = chroma_module
 self.client = chromadb.PersistentClient(path=db_path)
 self.collection = self.client.get_or_create_collection(...)
 self._validate_collection_dim()
 elif vs_config.provider == 'mock':
 self.client = "mock"
 self.collection = "mock"
 else:
 raise ValueError(f"Unknown vector store provider: {vs_config.provider}")
```

**Refactored pattern (Result + nil sentinel):**
```python
def _init_vector_store_result(self) -> Result[None]:
 vs_config = self.config.vector_store
 if vs_config.provider == 'chroma':
 db_path = os.path.abspath(...)
 os.makedirs(db_path, exist_ok=True)
 chroma_module = _get_chromadb()
 if chroma_module is None:
 return Result(data=None, errors=[ErrorInfo(kind=ErrorKind.CONFIG, message="chromadb is not installed", source="rag._init_vector_store")])
 chromadb, Settings = chroma_module
 self.client = chromadb.PersistentClient(path=db_path)
 self.collection = self.client.get_or_create_collection(...)
 return _validate_collection_dim_result() # cascades the result
 elif vs_config.provider == 'mock':
 self.client = "mock"
 self.collection = "mock"
 return Result(data=None)
 else:
 return Result(data=None, errors=[ErrorInfo(kind=ErrorKind.CONFIG, message=f"Unknown vector store provider: {vs_config.provider}", source="rag._init_vector_store")])

def _validate_collection_dim_result(self) -> Result[None]:
 if self.collection is None or self.collection == "mock" or self.embedding_provider is None:
 return Result(data=None)
 try:
 res = self.collection.get(limit=1, include=["embeddings"])
 ...
 except Exception as e:
 return Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"Failed to validate collection dim: {e}", source="rag._validate_collection_dim", original=e)])
 return Result(data=None)
```

**Key changes:**
- `_init_vector_store` becomes `_init_vector_store_result` returning `Result[None]`. `ImportError` and `ValueError` raises become `ErrorInfo` entries in the result.
- `_validate_collection_dim` becomes `_validate_collection_dim_result`. The catch-all `except Exception` becomes a `Result` with a single `ErrorInfo` (or success if the catch was a no-op).
- The `RAGEngine.is_empty`, `add_documents`, and other public methods return `Result` (or stay as their current return type if no error path exists).
- The `RAGEngine.__init__` itself stays as-is (it's a constructor; it sets `self.collection = NIL_COLLECTION` if init fails, deferring the error to the first operation).

**Nil sentinel for RAG:**
```python
@dataclass(frozen=True)
class NilRAGState:
 enabled: bool = False
 is_empty_result: bool = True
 errors: list[ErrorInfo] = field(default_factory=list)

NIL_RAG_STATE = NilRAGState()
```

Used when the RAG engine is in a "not configured" / "failed to init" state. Methods that would have raised now return `Result` with `data=NIL_RAG_STATE` and the error in `.errors`.

### 4.4 Convention Documentation

**`conductor/code_styleguides/error_handling.md`** (NEW, ~400 lines):

The canonical reference. Sections:
1. The 5 patterns (with Python code examples for each)
2. Decision tree: when to use Result vs Exception vs Optional
3. Naming conventions (`*_result` for Result-returning functions; `_result` suffix on dataclasses)
4. Error classification (the `ErrorKind` enum and when to use which)
5. Migration playbook (how to convert an `Optional[T]` return to `Result[T]`)
6. Anti-patterns (don't do these things)
7. Examples (the 3 refactored subsystems as worked examples)

**`conductor/product-guidelines.md`** (MODIFIED, +1 section):

New top-level section "Data-Oriented Error Handling":

```markdown
## Data-Oriented Error Handling

The codebase follows the "errors are just cases" framework from Ryan Fleury's
[The Easiest Way To Handle Errors](https://www.dgtlgrove.com/p/the-easiest-way-to-handle-errors).
The canonical reference (with code examples) is in
`conductor/code_styleguides/error_handling.md`. Key principles:

- **Result dataclasses** instead of Optional[T] or exception-based control flow.
- **Nil-sentinel dataclasses** instead of None.
- **Zero-initialized fields** via @dataclass defaults.
- **Fail early**: validation at the entry point, not deep in the call stack.
- **AND over OR**: return a struct with data + side-channel errors, not a sum type.
- **Exceptions reserved for the SDK boundary**: SDK errors are caught and converted
 to ErrorInfo dataclasses; the rest of the application works with data, not control flow.

This convention is established incrementally. The 2026-06-06 track applied it to
mcp_client.py, ai_client.py, and rag_engine.py. Future tracks will apply it to
the remaining src/ files.
```

**`conductor/workflow.md`** (MODIFIED, +1 line in the Code Style section):

```markdown
- For error handling, see [Data-Oriented Error Handling](./code_styleguides/error_handling.md).
```

**`docs/guide_ai_client.md`** (MODIFIED, +1 section):

```markdown
## Data-Oriented Error Handling (Fleury Pattern)

The provider layer uses `Result[str, ErrorInfo]` (returned by `_send_<vendor>_result()`)
instead of raising `ProviderError`. SDK exceptions are caught at the boundary
(see `send_openai_compatible` in `src/openai_compatible.py` and the DashScope
adapter in `src/qwen_adapter.py`) and converted to `ErrorInfo` entries in the
Result. The public `ai_client.send()` is deprecated; new code should use
`ai_client.send_result()`. See `conductor/code_styleguides/error_handling.md`
for the convention.
```

## 5. Configuration / Dependencies

### 5.1 New dependency: `typing_extensions`

For the `@deprecated` decorator (Python 3.11+ has `@warnings.deprecated` but it's Python 3.13+; `typing_extensions` backports it).

```toml
[project]
dependencies = [
 ...
 "typing_extensions>=4.5.0", # NEW
]
```

### 5.2 No new environment variables

All existing configs (`config.toml`, `credentials.toml`, per-project TOML) work unchanged.

## 6. Testing Strategy

| Test File | Purpose | Coverage Target |
|---|---|---|
| `tests/test_result_types.py` | `Result`, `ErrorInfo`, nil-sentinel singletons. | 100% |
| `tests/test_mcp_client_paths.py` | Verify `_resolve_and_check` returns `Result` (not tuple); verify `read_file` returns `Result[str]`. | 90% (covers the new code paths; existing tests still pass) |
| `tests/test_ai_client_result.py` | Verify `_send_<vendor>_result()` returns `Result`; verify `send_result()` is the new public API; verify `send()` emits `DeprecationWarning`. **State-delegation regression tests (added 2026-06-08 per `docs/guide_state_lifecycle.md` and the 2026-06-08 docs refresh):** verify that `app.temperature = 0.5` round-trips through the `App.__getattr__`/`__setattr__` delegation (per `gui_2.py:666-675`) and is visible in the next `send_result()` call; verify that `controller.disc_entries[i].content = "..."` is reflected in the next `send_result()`'s `messages` parameter (this is the regression vector for nagent_review Pitfall #4, the provider-history divergence); verify that the **6** per-provider history locks (`_anthropic_history_lock:128`, `_deepseek_history_lock:132`, `_minimax_history_lock:136`, `_qwen_history_lock:140`, `_grok_history_lock:145`, `_llama_history_lock:149` per `ai_client.py`) serialize correctly under concurrent `send_result()` calls from different threads. These tests are *mandatory* for Phase 3 (the ai_client refactor) because the `App.__getattr__`/`__setattr__` delegation means a partial refactor would manifest as silent `AttributeError`s deep in the test, not at the refactor commit boundary. | 90% |
| `tests/test_rag_engine_result.py` | Verify RAG methods return `Result`; verify `NilRAGState` is used. | 80% |
| `tests/test_deprecation_warnings.py` | Verify `ai_client.send()` emits exactly one `DeprecationWarning` per call site (cached after first). | 100% |
| `tests/test_mcp_client.py` (existing) | Verify no regressions; existing tests pass unchanged. | 100% (regression) |
| `tests/test_ai_client.py` (existing) | Verify no regressions; existing tests pass unchanged. | 100% (regression) |
| `tests/test_rag_engine.py` (existing) | Verify no regressions; existing tests pass unchanged. | 100% (regression) |

**Mocking strategy:** Existing tests use `unittest.mock.patch` on SDK calls; no changes needed. New tests use the same pattern.

**Baseline verification (Phase 1):** Run a project-wide grep to record the post-tracks baseline:
```bash
rg "ai_client\.send\(" --type py | wc -l # direct callers of the public send()
rg "_send_(gemini|anthropic|deepseek|minimax|gemini_cli|qwen|llama|grok)\(" src/ -n # direct callers of private _send_<vendor>() — should be 0 post-qwen-track
rg "Optional\[" src/mcp_client.py src/ai_client.py src/rag_engine.py | wc -l # baseline Optional usage in the 3 refactored files
```

The numbers go in `state.toml [verification]`:
```toml
[baseline_post_qwen_track]
ai_client_send_callers_in_src = 0  # will be 0 — this track is upstream of callers
ai_client_send_callers_in_tests = 0  # record actual count from rg
optional_in_3_files = 0  # record actual count from rg
```

The follow-up `public_api_migration_20260606` track uses these as its starting baseline. The `no_new_optional_in_3_files` verification criterion is "the count does not grow during this track" — verified by re-running the grep at Phase 2, 3, 4, 5 checkpoints.

**Integration verification:** Manual smoke test in the GUI: send a message that exercises the new patterns end-to-end. Document the smoke test in the Phase 5 checkpoint git note.

## 7. Migration / Rollout

| Phase | What | Risk |
|---|---|---|
| **Phase 1 — Foundation: patterns module + style guide** | Add `src/result_types.py`. Add `conductor/code_styleguides/error_handling.md`. Update `product-guidelines.md` and `workflow.md`. Add `typing_extensions` dep. | None. New files, no modifications. |
| **Phase 2 — `mcp_client.py` refactor** | Refactor `_resolve_and_check` + the 9 tool functions. The 30+ `assert p is not None` become nil-sentinel usage. The `(p, err)` tuples become `Result`. | Medium. ~60 sites. Mitigated by existing `tests/test_mcp_client.py` coverage. |
| **Phase 3 — `ai_client.py` refactor** | Refactor `_classify_*_error()` → return `ErrorInfo`. Refactor `_send_*` → `_send_*_result()` returning `Result`. Add `send_result()` public API. Mark `send()` `@deprecated`. | High. The provider layer is the most complex refactor. Mitigated by existing `tests/test_minimax_provider.py`, `tests/test_qwen_provider.py`, etc. |
| **Phase 4 — `rag_engine.py` refactor** | Refactor RAG methods to return `Result`. Add `NilRAGState` sentinel. | Medium. ~20 sites. Mitigated by existing `tests/test_rag_engine.py`. |
| **Phase 5 — Deprecation + docs + integration** | Wire deprecation warning. Update `docs/guide_ai_client.md` and `docs/guide_mcp_client.md`. Add the public_api_migration_20260606 placeholder to `conductor/tracks.md`. Manual smoke test. | Low. |

Each phase has its own checkpoint commit and git note.

## 8. Risks & Mitigations

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| `ProviderError` is currently raised from `_classify_*_error()`. The refactor changes these to return `ErrorInfo` instead. Any external caller that catches `ProviderError` will break. | Low | Medium | Search the codebase: `rg "except ProviderError"`. Per the grep above (line 1451 of `ai_client.py`), `ProviderError` is only caught in `ai_client.send()` (defined at `ai_client.py:2690`). After the refactor, that catch becomes a `result.errors` check. No external code catches `ProviderError` directly. The 4 in-file classifier functions (`_classify_anthropic_error:361`, `_classify_gemini_error:380`, `_classify_deepseek_error:396`, `_classify_minimax_error:420`) plus 1 shared `_classify_openai_compatible_error` in `src/openai_compatible.py:39` plus `classify_dashscope_error` in `src/qwen_adapter.py:26` are the 6 conversion sites — `_classify_gemini_cli_error` does not exist (Gemini CLI uses `GeminiCliAdapter` subprocess path with internal error handling). |
| The 30+ `assert p is not None` in `mcp_client.py` are existing invariants that catch real bugs. If the refactor turns them into nil-sentinel paths, a real bug could manifest as a silent empty result. | Medium | High | The refactored code keeps the assertions as `assert resolved.ok` or `assert not isinstance(resolved.data, NilPath)` where the invariants matter. The `Result.errors` list captures the failure for the caller. |
| Adding `@deprecated` to `send()` produces a lot of `DeprecationWarning` log spam in the test suite. | High | Low | The deprecation message is cached per call site (using `warnings.warn(..., stacklevel=2)` with a `DeprecationWarning` filter that doesn't propagate to the test failure). Tests can opt in to the warning check via `pytest.warns(DeprecationWarning)`. |
| `result_types.py` introduces a circular import risk (if `models.py` or other core modules want to use `ErrorKind` early). | Low | Low | `result_types.py` is a leaf module with no imports from other src files except stdlib. |
| The MCP dispatch internals (which call `read_file`, `list_directory`, etc.) currently expect a `str` return. The refactor returns `Result[str]`. | Medium | Medium | The dispatch layer is updated in Phase 2 alongside the tool functions. The dispatch unwraps `Result.data` and logs `Result.errors` via the comms log. The dispatch's public API (the `async_dispatch` function) still returns `str` to the AI model. |
| The `RAGEngine.__init__` constructor currently raises if config is invalid. The refactor wants to defer errors to first use. | Medium | Low | Constructor still raises for "config missing" (fail early at init). "Config invalid" (e.g., bad embedding provider) defers to `_init_vector_store_result` (called explicitly or lazily). |

## 9. Open Questions

1. **The Result type generic syntax:** Python 3.11+ supports `Generic[T]` cleanly. The spec uses `Result[T]`. Should we also provide a non-generic `Result` for cases where the data is always `None` (e.g., `Result[None]` for operations that succeed/fail without data)? (Proposal: yes; provide `Ok = Result(data=None, errors=[])` as a constant for the trivial success case.)
2. **Logging of errors:** When `_send_<vendor>_result()` returns a `Result` with errors, should the errors be auto-logged via `_append_comms`, or should the caller decide? (Proposal: auto-log errors as `WARN` entries in the comms log; this matches today's behavior where `ProviderError` was logged.)
3. **Backwards-compat shim for the old `(p, err)` returns:** Some internal callers might still be unpacking `(p, err)`. Should the refactor break them or provide a shim? (Proposal: break them. The grep above shows the pattern is contained; the breakage is in tool functions, not in the public MCP API.)
4. **Should the `Result` type be in a more general location?** E.g., `src/result_types.py` is fine for v1; if the patterns spread to other tracks, it could move to `src/result.py` or `src/datatypes/result.py`. (Proposal: keep `src/result_types.py` for v1; revisit if it becomes a multi-track import.)

## 10. Coordination with Pending Tracks (post-state baseline)

This track executes **after** three pending tracks have landed (or are far enough along that the codebase reflects their state). The spec assumes the following baseline when this track begins. Any drift from this baseline is a coordination issue that the implementer must resolve before Phase 1.

### 10.1 Post-`startup_speedup_20260606` State

- **`src/startup_profiler.py`** exists (new module with `StartupProfiler` context manager).
- **`src/app_controller.py`** has `AppController._io_pool: ThreadPoolExecutor` (4 workers, prefix `controller-io-N`) for background work.
- **`src/app_controller.py`** has a warmup mechanism: `_warmup_status`, `_warmup_done_event`, `on_warmup_complete`, `wait_for_warmup`.
- **`src/ai_client.py`** has `import` statements restructured: heavy SDKs (`google.genai`, `anthropic`, `openai`, `fastapi`) are accessed via `_require_warmed(name)` at use sites, NOT top-level imports. `import src.ai_client` is < 50ms.
- **`src/api_hooks.py`** has FastAPI imports deferred similarly. `import src.api_hooks` is < 100ms.
- **`src/commands.py`, `src/command_palette.py`, `src/theme_2.py`, `src/theme_nerv.py`, `src/theme_nerv_fx.py`, `src/markdown_helper.py`** all have heavy imports moved to use-sites.
- **No new `threading.Thread(...)` calls** anywhere in `src/` (per the track's invariant).
- **Top-level `Optional[X]` in `src/ai_client.py`** is reduced (SDK clients now accessed via `_require_warmed`). But the function signatures still use `Optional[X]` for callbacks and config (e.g., `pre_tool_callback: Optional[Callable]`).
- **`scripts/audit_main_thread_imports.py`** is a CI gate that fails if heavy imports appear at the top of main-thread-reachable files.

**Impact on this track:**
- The new `src/result_types.py` is a leaf module with only stdlib imports. Safe to import at top of any file. **Verify** with the audit script in Phase 1.
- The new `_send_<vendor>_result()` functions may need to be careful about the warmup mechanism: if the SDK isn't warmed, `_require_warmed(name)` is called inside `_ensure_<vendor>_client()`, which is itself called from `_send_<vendor>_result()`. The Result pattern's "fail at boundary, convert to ErrorInfo" applies: if `_require_warmed` raises, catch and convert.

### 10.2 Post-`test_batching_refactor_20260606` State

- **`scripts/run_tests_batched.py`** is the new categorized batcher with `--plan` and `--audit` modes.
- **`scripts/test_categorizer.py`** + **`scripts/test_batcher.py`** + **`scripts/pytest_collection_order.py`** exist.
- **`tests/test_categories.toml`** is populated with ~30 cross-cutting entries.
- **`tests/conftest.py`** registers the `pytest_collection_order` plugin.
- **All new tests** in this track will be auto-classified by the categorizer. Pure unit tests go to Tier 1; `live_gui` tests (if any) go to Tier 3. Most new tests for this track are Tier 1 (unit).

**Impact on this track:**
- New test files (`test_result_types.py`, `test_mcp_client_paths.py`, `test_ai_client_result.py`, `test_rag_engine_result.py`, `test_deprecation_warnings.py`) should follow the standard naming convention. The categorizer will classify them automatically.
- If any of these tests need `mock_app` or `app_instance` fixtures, they're Tier 2. If any need `live_gui`, they're Tier 3.
- The `test_batching_refactor` track's registry may want a `test_ai_client_result.py` entry to ensure it goes to the right batch_group (likely `core` or `mma`).

### 10.3 Post-`qwen_llama_grok_integration_20260606` State (most impactful)

This is the track that most affects the data-oriented error handling refactor. The state:

#### 10.3.1 New modules in `src/`

- **`src/vendor_capabilities.py`**: `VendorCapabilities` dataclass, `_REGISTRY` populated for Qwen/Llama/Grok/MiniMax + Anthropic/Gemini/DeepSeek stubs, `get_capabilities(vendor, model)`, `list_models_for_vendor(vendor)`.
- **`src/openai_compatible.py`**: `NormalizedResponse`, `OpenAICompatibleRequest`, `send_openai_compatible(client, request, capabilities)` that **raises** `ProviderError` via `_classify_openai_compatible_error()` on SDK errors.
- **`src/qwen_adapter.py`**: `build_dashscope_tools()`, `classify_dashscope_error()` that **raises** `ProviderError`.

#### 10.3.2 Modified `src/ai_client.py`

- **All 5 providers** (`_send_gemini`, `_send_anthropic`, `_send_deepseek`, `_send_minimax`, `_send_gemini_cli`) plus 3 new vendors (`_send_qwen`, `_send_llama`, `_send_grok`) plus the Ollama native adapter (`_send_llama_native`, added by the `qwen_llama_grok_followup_20260611` track for `localhost` / `127.0.0.1` base URLs) all exist. **9 `_send_*()` functions total.** All return `str` (text content of the AI response).
- **Per-vendor state**: state globals for all 5+3+1 providers; per-vendor history lists + **6 per-vendor history locks** (`_anthropic_history_lock`, `_deepseek_history_lock`, `_minimax_history_lock`, `_qwen_history_lock`, `_grok_history_lock`, `_llama_history_lock`); per-vendor client singletons.
- **Per-vendor `list_models()`** dispatch exists.
- **Shared `run_with_tool_loop` helper** (added 2026-06-11 by `qwen_llama_grok_followup_20260611`, `ai_client.py:806`): 4 of 9 vendors already use it — `_send_minimax` (refactored to helper in Phase 4 of the parent track, 250 → 50 lines), `_send_grok`, `_send_llama`, and `_send_gemini_cli` (via the `send_func + on_pre_dispatch` extension). The remaining 5 vendors (`_send_anthropic`, `_send_gemini`, `_send_deepseek`, `_send_qwen`, `_send_llama_native`) still have bespoke inline tool-call loops. **Invariant preserved by the audit gate** `scripts/audit_no_inline_tool_loops.py` (`DEFERRED_VENDORS = {"anthropic", "gemini", "deepseek"}`): after this track, the 4 refactored vendors must still use `run_with_tool_loop` (and the 3 deferred vendors remain in the exclusion list). `_send_qwen` and `_send_llama_native` are NOT in the deferred list, so any inline loop in them is already a CI violation.
- **MiniMax is already refactored** to use `send_openai_compatible()` and `run_with_tool_loop` (the data-oriented refactor in the parent track reduced `_send_minimax` from ~250 lines to ~50).
- **Anthropic and DeepSeek** still have their bespoke `_send_*()` implementations.
- **Gemini** still has its SDK-specific caching logic (4-breakpoint system, explicit `genai.CachedContent`).
- **Gemini CLI** still has its subprocess adapter (`GeminiCliAdapter` in `src/gemini_cli_adapter.py`).
- **`_send_llama_native`** is a thin Ollama wrapper at `ai_client.py:~2540` (post the `qwen_llama_grok_followup_20260611` track). It POSTs to `/api/chat` (not `/v1/chat/completions`) and supports `think` / `images` / `thinking` fields. It is dispatched from `_send_llama` when the base URL is `localhost` / `127.0.0.1`. No `run_with_tool_loop` refactor — it delegates up to `_send_llama`'s loop.

#### 10.3.3 Critical coordination questions for THIS track

**Q1: How to handle the existing `_send_<vendor>()` functions (which all return `str`)?**

Two options:

- **Option A (rename)**: Rename `_send_<vendor>()` to `_send_<vendor>_result()` and change the return type to `Result[str]`. The `send_result()` public API calls these directly. The deprecated `send()` public API calls these and unwraps. **Cleaner end state.** The internal callers (just `send()` and `send_result()`) update together.
- **Option B (add new)**: Add NEW `_send_<vendor>_result()` functions alongside the existing `_send_<vendor>()`. Old functions stay; new functions do the Result conversion. `send_result()` calls the new ones. The deprecated `send()` calls the old ones. **Lower risk, more code.** Eventually the old functions get deleted in a follow-up track.

**This track uses Option A.** Rationale: the existing `_send_<vendor>()` functions are private (underscore prefix); only the `send()` and `send_result()` public APIs call them. Renaming + retuning the return type is contained. Test code that calls `_send_*()` directly is rare (the public `send()` is the test entry point) and easy to update.

**Q2: Does `send_openai_compatible` (in `src/openai_compatible.py`) need to change?**

**No.** Per Fleury: "exceptions are reserved for the SDK boundary." `send_openai_compatible` IS the SDK boundary for OpenAI-compatible vendors. It correctly catches `OpenAIError` and raises `_classify_openai_compatible_error(exc)`. The calling `_send_<vendor>_result()` (in `src/ai_client.py`) catches the raised `ProviderError` and converts it to an `ErrorInfo` inside a `Result[str]`. This is the **correct layering**: SDK raises → boundary catches → caller converts.

Similarly, `classify_dashscope_error` in `src/qwen_adapter.py` keeps raising. `_send_qwen_result()` catches and converts.

**Q3: Does the deprecated `send()` deprecation warning cause test spam?**

Yes. Most of the existing test files call `ai_client.send()`. Adding `@deprecated` to `send()` will produce a `DeprecationWarning` for each call. The deprecation warning is emitted at runtime via `warnings.warn(DeprecationWarning, stacklevel=2)`.

Mitigations:
- `warnings.warn` only emits the warning once per call site by default (Python's `__warningregistry__`).
- The conftest.py's `filterwarnings` setting can be configured to silence `DeprecationWarning` from specific modules.
- The deprecation warning is **advisory**; the tests still pass. The agent implementing this track should add a `filterwarnings` entry to `tests/conftest.py` (or per-test) to silence the warning during the transition period.
- The follow-up `public_api_migration_20260606` track (planned in §13.1) removes the deprecation entirely.

**Q4: Does the deprecation warning conflict with the existing `ProviderError` import?**

The deprecated `send()` no longer raises `ProviderError` (it returns `str` from the `Result.data` field, even if there were errors, matching today's behavior). The `except ProviderError` clauses in `src/ai_client.py` (e.g., line 1338) become dead code that can be removed in Phase 3 of this track.

**Q5: How do the new `_send_<vendor>_result()` functions interact with the existing `ProviderError`?**

Two options:
- Keep `ProviderError` as the internal exception type that `_classify_*_error()` raises. `_send_<vendor>_result()` catches it and converts to `ErrorInfo`. `ProviderError` becomes a pure SDK-boundary exception.
- Replace `ProviderError` entirely with `ErrorInfo` from `src/result_types.py`. `_classify_*_error()` returns `ErrorInfo` (a value, not an exception). `_send_<vendor>_result()` doesn't need to catch anything; the classifier returns the `ErrorInfo` directly.

**This track uses the second option (full replacement).** Rationale: keeping `ProviderError` as an internal exception defeats the purpose of the Fleury refactor. The whole point is "errors are data, not control flow." `ProviderError` is removed; `ErrorInfo` is its replacement.

**Q6: What about the `ProviderError.ui_message()` method?**

It moves to `ErrorInfo.ui_message()` (already in the design in §3.3). All call sites that used `exc.ui_message()` now use `err_info.ui_message()` (where `err_info: ErrorInfo` is from `result.errors[0]` or similar).

### 10.4 Baseline verification (Phase 1 task)

Before any refactor, the implementer runs:

```bash
git log --oneline -1 conductor/tracks/qwen_llama_grok_integration_20260606/ # confirm qwen track merged
git log --oneline -1 conductor/tracks/test_batching_refactor_20260606/  # confirm batching track merged
git log --oneline -1 conductor/tracks/startup_speedup_20260606/          # confirm startup track merged
ls src/result_types.py 2>/dev/null && echo "ALREADY EXISTS" || echo "OK to create"
ls src/vendor_capabilities.py 2>/dev/null && echo "OK" || echo "MISSING — qwen track not merged?"
ls src/openai_compatible.py 2>/dev/null && echo "OK" || echo "MISSING — qwen track not merged?"
```

If any of the expected new files are missing, the implementer reports a coordination issue to the Tier 2 Tech Lead. **Do NOT proceed** with the data-oriented refactor until the post-state baseline is verified.

## 11. Out of Scope (Explicit)

- **Migrating the remaining `src/` files** (`app_controller.py`, `models.py`, `project_manager.py`, `commands.py`, `events.py`, `session_logger.py`, `multi_agent_conductor.py`, `hot_reloader.py`, etc.). The convention is established so these can be migrated one at a time in future tracks. See §12.2 for a prioritized list of follow-up migration tracks.
- **Removing the deprecated public `ai_client.send()`.** The `@deprecated` marker is added; removal happens in the public_api_migration_20260606 track.
- **Migrating the MMA worker interface** (`multi_agent_conductor.py` calls `ai_client.send()` for each worker). Deferred to the public_api_migration_20260606 track.
- **Async / asyncio error propagation patterns.** Out of scope for this track.
- **The `UserRequestEvent` and `Execution Clutch` HITL patterns** in `app_controller.py`. These are about user interaction, not error propagation. Deferred.
- **The `EventEmitter` cross-thread event patterns** in `events.py`. Out of scope.
- **Preserving the `scripts/audit_no_inline_tool_loops.py` CI gate** (added by `qwen_llama_grok_followup_20260611`): the 4 refactored vendors must keep using `run_with_tool_loop`. Any vendor that drops the helper after the refactor will fail CI. The 3 deferred vendors (`anthropic`, `gemini`, `deepseek`) remain in the exclusion list.

## 12. See Also

### 12.1 Follow-up Track (planned in §12.1 placeholder; detailed in conductor/tracks.md)

**"Public API Result Migration"** (`public_api_migration_20260606`) — Removes the deprecated `ai_client.send()`. Migrates all callers to `send_result()`. Adds any new public API surface needed (e.g., per-ticket `Result` returns in the MMA conductor). This is the **only** follow-up that this spec plans; the other future migrations are listed below for reference but not planned here.

**Baseline verification (run during the follow-up track's Phase 1):**
The complete list of `ai_client.send()` direct callers in `src/` (verified 2026-06-11):
- `src/app_controller.py:290` — `_api_generate` body
- `src/app_controller.py:3692` — second call site (was `:3559` in the 2026-06-08 audit; the line drifted as additional code landed above the call)
- `src/multi_agent_conductor.py:591` — MMA worker dispatch
- `src/orchestrator_pm.py:86` — orchestrator project manager
- `src/conductor_tech_lead.py:68` — Tech Lead sub-agent
- `src/mcp_client.py:2274` — **NEW (added 2026-06-11, missed in the original §12.1 enumeration):** the MCP tool-result dispatch path. When the `mcp_client.async_dispatch` path returns an error string from a tool, the surrounding code may route through `ai_client.send()` for retry-classification. This is the 5th production caller in `src/`.

Plus **63** test files (verified 2026-06-11) that call `send()` directly. The follow-up track's `rg "ai_client\.send\(" --type py | wc -l` baseline should match these numbers before migration begins. Tests that call `_send_<vendor>()` directly (rather than `send()`) are also affected by the `Task 3.4` rename and need migration to `_send_<vendor>_result()`.

### 12.2 Future Migration Tracks (prioritized; NOT planned in this spec)

1. **`app_controller.py` migration** — ~199 `Optional[X]` uses, ~30+ `except Exception` blocks. Highest priority because `app_controller.py` is the orchestrator and touches every subsystem.
2. **`models.py` migration** — many `Optional[X]` fields in dataclasses. These can be migrated to default values (e.g., `script: str = ""` instead of `script: Optional[str] = None`).
3. **`project_manager.py`, `session_logger.py`, `events.py`, `commands.py` migration** — smaller files, lower priority.
4. **`multi_agent_conductor.py` migration** — once `app_controller.py` is done.
5. **`hot_reloader.py`, `performance_monitor.py`, `summarize.py`, `outline_tool.py` migration** — utility modules, last priority.

### 12.3 Project References

- `docs/guide_ai_client.md` — current provider architecture; will be updated in Phase 5. The per-provider history globals (`_anthropic_history`, `_deepseek_history`, `_minimax_history` at `ai_client.py:123-132`) are the **specific pattern** that the `ErrorKind.PROVIDER_HISTORY_DIVERGED_FROM_UI` new error kind (added 2026-06-08) is designed to surface. Per `guide_ai_client.md §"State"`, the per-provider-lock pattern is the established convention.
- `docs/guide_mcp_client.md` — current MCP client architecture; will be updated in Phase 5. Per the 2026-06-08 docs refresh, `guide_mcp_client.md` documents the 3-layer security model (Allowlist Construction → Path Validation → Resolution Gate) that the mcp_client refactor must preserve. The new `Result` return type must not weaken the 3 layers.
- `docs/guide_state_lifecycle.md` — added 2026-06-08. The 3 per-thread + 7-lock pattern documented in §4 ("State Synchronization Across Threads") is what the `ai_client` refactor's state-delegation regression tests must exercise.
- `docs/guide_discussions.md` — added 2026-06-08. The 23-operation matrix (A1-A7 + B1-B11 + C1-C5) is the *user-facing* source of truth for what the per-entry edit operations do. The provider-history-divergence issue (Pitfall #4 from the nagent_review) is exactly that: user edits `disc_entries[i].content` via A1, but `ai_client._<provider>_history` is not updated. The follow-up `public_api_migration_20260606` is the natural moment to fix this.
- `docs/guide_context_aggregation.md` — added 2026-06-08. The `aggregate.py:109 build_discussion_section` consumes the `disc_entries` list. If the entries are edited via A1, the section regenerates correctly. If the provider history is *not* updated, the next LLM call still sees the old history. The `Result` pattern from this track is the natural carrier for the "diverged" signal.
- `conductor/tracks/qwen_llama_grok_integration_20260606/` — the previous track that introduced the "data-oriented" framing; this track extends that philosophy to error handling. The qwen track's `send_openai_compatible()` helper is *expected* to return `Result` from day 1 (per the coordination note in the qwen spec §3.1) — this is a real concrete dependency.
- `conductor/tracks/mcp_architecture_refactor_20260606/` — the next major track (after this one). Each sub-MCP's `invoke()` returns `Result[str, ErrorInfo]` per the mcp spec; this track defines the `Result` type that the mcp refactor uses. Coordination: this track ships *before* the mcp refactor can ship Phase 4 (extract Python) onward.
- `conductor/tracks/nagent_review_20260608/report.md` — added 2026-06-08. §15 Pitfalls #2 and #4 (per-provider history globals, stateful singleton) and Pitfall #9 (sub-conversations) inform this track's risk register. Pitfall #4 specifically motivates the new `ErrorKind.PROVIDER_HISTORY_DIVERGED_FROM_UI` kind.
- `conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md` — added 2026-06-08. §9 ("Edit-the-input, not the output") describes the same provider-history-divergence problem; the `Result` pattern + the new error kind are the data-oriented solution.
- `conductor/tracks/test_batching_refactor_20260606/` — the previous track that established the "tier-based" pattern; this track uses the same convention format (spec + metadata + state + plan).

### 12.4 External References

- **Ryan Fleury, "The Easiest Way To Handle Errors Is To Not Have Them"** — the framework this track implements.
- **Digital Grove codebase** — Fleury's reference C codebase where the patterns are most fully developed.
- **Mike Acton on data-oriented design** — the "data is the API" framing that motivates the Result/nil-sentinel patterns.
