<!-- STATUS: COMPLETE -->

# Phase 11: Interface Contract Generation

## Goal

Document what the LLM module *should* do by generating comprehensive interface contracts for all public functions. These contracts will be used by Phase 12 (test reset) to write tests from specifications, not implementation memory.

## Dependencies

- **Phase 10** (Pipeline stabilization) — All code changes complete and verified
- **my-style skill** — For contract format guidance

## Context

This phase runs in a fresh session to ensure contracts are written from requirements understanding, not implementation familiarity. The output will be interface contracts stored in `_planning/project-requirements/interface_contracts/` that define:

1. Function signatures with types
2. Purpose (one sentence)
3. Invariants (rules that always hold)
4. Valid inputs and constraints
5. Error conditions and what errors look like
6. Edge cases with expected return values

---

## Research Notes

### LLM Module Structure

```
src/unfamiliar/llm/
├── __init__.py              # Public API exports
├── exceptions.py            # 6 exception classes
├── retry.py                 # with_llm_retry decorator
├── context.py               # build_briefing_context()
├── structured.py            # JSON parsing, TruncationResult, detect_truncation()
├── providers/
│   ├── __init__.py          # create_chat_model export
│   └── factory.py           # create_chat_model(), get_retry_delay()
├── briefing/
│   ├── __init__.py          # generate_briefing, BriefingResult exports
│   ├── schemas.py           # BriefingResponse, ModuleDescription
│   ├── prompts.py           # BRIEFING_PROMPT template
│   └── generator.py         # generate_briefing()
└── missions/
    ├── __init__.py          # run_mission_pipeline, CATEGORY_ORDER exports
    ├── schemas.py           # 5 Pydantic models for mission data
    ├── prompts.py           # MISSION_PROMPT template
    ├── context.py           # build_mission_context(), build_module_descriptions()
    ├── generator.py         # generate_category_missions(), generate_category_missions_multi()
    ├── validation.py        # validate_category_missions(), validate_file_refs(), validate_prerequisites()
    └── pipeline.py          # run_mission_pipeline(), PipelineResult
```

### Public Functions Requiring Contracts

| Module | Function | Purpose |
|--------|----------|---------|
| `llm/__init__.py` | (exports only) | Re-exports public API |
| `llm/exceptions.py` | (classes only) | Exception hierarchy |
| `llm/retry.py` | `with_llm_retry` | Execute with exponential backoff |
| `llm/context.py` | `build_briefing_context` | AnalysisResult → LLM text |
| `llm/structured.py` | `create_structured_chain` | Build chain with structured output |
| `llm/structured.py` | `detect_truncation` | Detect truncated JSON |
| `llm/structured.py` | `TruncationResult` | Dataclass for truncation info |
| `llm/providers/factory.py` | `create_chat_model` | Create LangChain chat model |
| `llm/providers/factory.py` | `get_retry_delay` | Provider-specific retry delay |
| `llm/providers/factory.py` | `get_effective_token_budget` | Cap budget by provider |
| `llm/briefing/generator.py` | `generate_briefing` | Generate codebase briefing |
| `llm/briefing/generator.py` | `BriefingResult` | Output dataclass |
| `llm/missions/generator.py` | `generate_category_missions` | Single-call mission gen |
| `llm/missions/generator.py` | `generate_category_missions_multi` | Multi-call mission gen |
| `llm/missions/context.py` | `build_mission_context` | Category-specific context |
| `llm/missions/context.py` | `build_module_descriptions` | Module index string |
| `llm/missions/validation.py` | `validate_category_missions` | Orchestrate validation |
| `llm/missions/validation.py` | `validate_file_refs` | Validate file paths/lines |
| `llm/missions/validation.py` | `validate_prerequisites` | Validate prereq graph |
| `llm/missions/validation.py` | `ValidationResult` | Output dataclass |
| `llm/missions/pipeline.py` | `run_mission_pipeline` | Full 7-category pipeline |
| `llm/missions/pipeline.py` | `PipelineResult` | Output dataclass |

### Key Invariants Identified

1. **Retry invariants:**
   - 429/5xx errors always retry (up to max_attempts)
   - 401/403 never retry (immediate AuthenticationError)
   - Schema validation errors retry (GLM may succeed on retry)

2. **Token budget invariants:**
   - `get_effective_token_budget()` never exceeds provider max
   - `build_briefing_context()` always respects token_budget
   - Global truncation applied if total exceeds budget

3. **Validation invariants:**
   - Every category has at least 1 root mission (no prereqs)
   - No prerequisite cycles (broken by validation)
   - Max prerequisite depth enforced (MAX_PREREQUISITE_DEPTH)

4. **Pipeline invariants:**
   - Categories processed in CATEGORY_ORDER
   - Partial results returned on error
   - Auth errors stop pipeline immediately

---

## Tasks

### Task 1: Create project-requirements directory structure

**Action:**
1. Create `_planning/project-requirements/` directory
2. Create `_planning/project-requirements/interface_contracts/` subdirectory
3. Create `_planning/project-requirements/index.md` with summary

**Verify:**
- Directory structure exists
- index.md created

**Done when:**
- `ls _planning/project-requirements/interface_contracts/` shows empty directory ready for files

---

### Task 2: Generate llm_core.md — exceptions, retry, context

**File:** `_planning/project-requirements/interface_contracts/llm_core.md`

**Action:**
Write interface contracts for:
- `LLMError`, `ProviderError`, `AuthenticationError`, `BriefingGenerationError`, `MissionGenerationError`, `TokenBudgetExceededError`
- `with_llm_retry(fn, *args, max_attempts, base_delay, provider_name, **kwargs)`
- `build_briefing_context(analysis, token_budget)`

**Contract format:**
```markdown
### function_name

**Signature:**
```python
def function_name(param: type) -> return_type
```

**Purpose:** One sentence description.

**Invariants:**
- Rule that always holds
- Another invariant

**Valid inputs:**
- `param`: constraints and valid values

**Error conditions:**
- Condition → ExceptionType with message pattern

**Edge cases:**
- Empty input → returns X, not error
- Boundary case → returns Y

**Verify:**
- All 3 modules covered
- All public functions have contracts
- Edge cases include expected return values

**Done when:**
- File contains complete contracts for exceptions, retry, and context

---

### Task 3: Generate llm_structured.md — JSON parsing

**File:** `_planning/project-requirements/interface_contracts/llm_structured.md`

**Action:**
Write interface contracts for:
- `TruncationResult` (dataclass)
- `detect_truncation(text, schema)`
- `create_structured_chain(model, prompt, schema)`
- `_extract_json(text)` (internal but critical)

**Verify:**
- TruncationResult fields documented with types
- detect_truncation edge cases covered (empty, complete, truncated)
- create_structured_chain provider behavior documented

**Done when:**
- File contains complete contracts for structured output module

---

### Task 4: Generate llm_providers.md — factory functions

**File:** `_planning/project-requirements/interface_contracts/llm_providers.md`

**Action:**
Write interface contracts for:
- `ProviderConfig` (dataclass)
- `create_chat_model(config, max_tokens)`
- `get_retry_delay(provider_name)`
- `get_effective_token_budget(config)`

**Verify:**
- Provider-specific behavior documented (anthropic vs glm)
- Token budget capping explained
- Error conditions for unknown providers

**Done when:**
- File contains complete contracts for provider factory

---

### Task 5: Generate llm_briefing.md — briefing pipeline

**File:** `_planning/project-requirements/interface_contracts/llm_briefing.md`

**Action:**
Write interface contracts for:
- `BriefingResponse` (Pydantic model)
- `ModuleDescription` (Pydantic model)
- `BriefingResult` (dataclass)
- `generate_briefing(analysis, model, token_budget, provider_name)`

**Verify:**
- Pydantic field descriptions captured
- BriefingResult fields documented
- Module padding behavior documented (when LLM returns fewer than expected)

**Done when:**
- File contains complete contracts for briefing generation

---

### Task 6: Generate llm_missions.md — mission pipeline

**File:** `_planning/project-requirements/interface_contracts/llm_missions.md`

**Action:**
Write interface contracts for:
- `MissionFileRefResponse`, `MissionItemResponse`, `ChainResponse`, `CategoryMissionsResponse` (Pydantic models)
- `build_mission_context(analysis, briefing_result, category, previous_missions, token_budget)`
- `build_module_descriptions(briefing_result)`
- `generate_category_missions(...)` (single-call)
- `generate_category_missions_multi(...)` (multi-call)
- `validate_category_missions(missions_response, repo_path, category, chains)`
- `validate_file_refs(file_refs, repo_path)`
- `validate_prerequisites(missions, category)`
- `ValidationResult` (dataclass)
- `run_mission_pipeline(analysis, briefing_result, model, campaign_dir, token_budget, provider_name, category_cooldown)`
- `PipelineResult` (dataclass)

**Verify:**
- All Pydantic models documented
- Validation auto-fix behaviors documented
- Pipeline category order and error handling documented
- Multi-call truncation resume documented

**Done when:**
- File contains complete contracts for entire mission pipeline

---

### Task 7: Update project-requirements/index.md

**File:** `_planning/project-requirements/index.md`

**Action:**
Create index file that:
1. Lists all interface contract files with brief descriptions
2. Provides navigation to each contract file
3. Notes which phases the contracts are used for (Phase 12 testing)

**Verify:**
- Index is well-organized
- All contract files referenced

**Done when:**
- index.md provides clear navigation to all contracts

---

## Output Structure

```
_planning/project-requirements/
├── index.md                        # Navigation and summaries
└── interface_contracts/
    ├── llm_core.md                 # Exceptions, retry, context
    ├── llm_structured.md           # JSON parsing, TruncationResult
    ├── llm_providers.md            # Factory, retry delays
    ├── llm_briefing.md             # Briefing generation
    └── llm_missions.md             # Mission pipeline (largest file)
```

---

## Verify Criteria

- [ ] All 5 contract files created
- [ ] All public functions have contracts
- [ ] All Pydantic models documented
- [ ] All dataclasses documented
- [ ] Edge cases include expected return values (not just descriptions)
- [ ] Error conditions specify exception types and message patterns
- [ ] Provider-specific behavior documented (anthropic vs glm)
- [ ] index.md provides clear navigation

---

## Out of Scope

- Test writing (Phase 12)
- Code changes
- Private/internal functions (only public API)
- Analysis module contracts (out of scope for this phase)

---

## Post-Plan Note

This phase produces documentation only. Phase 12 (test reset) will use these contracts to write tests that verify behavior, not implementation details.

---

# Interface Contracts

**Generated:** 2026-03-14

The following contracts document what the LLM module *should* do. Phase 12 will use these to write tests from specifications, not implementation memory.

---

## 1. LLM Core (exceptions, retry, context)

**Modules:** `llm/exceptions.py`, `llm/retry.py`, `llm/context.py`

### Exception Hierarchy

#### LLMError

```python
class LLMError(Exception):
    """Base exception for all LLM-related errors."""
```

**Purpose:** Base class for all LLM errors — catch this to handle any LLM failure.

**Invariants:**
- All LLM-specific exceptions inherit from this class
- Message is always human-readable

**Valid usage:**
- `except LLMError as e:` catches all LLM errors
- `raise LLMError("message")` for generic LLM failures

---

#### ProviderError(LLMError)

```python
class ProviderError(LLMError):
    """Raised when a provider cannot be initialized or is unknown."""
```

**Purpose:** Indicates configuration or initialization problems with an LLM provider.

**Invariants:**
- Only raised during `create_chat_model()` setup
- Never raised during LLM invocation (that's AuthenticationError)

**Error conditions:**
- Unknown provider → `ProviderError: Unknown provider: 'xyz'. Supported providers: anthropic, glm`
- Missing API key → `ProviderError: No API key found for provider 'anthropic'...`
- Missing package → `ProviderError: Failed to initialize glm provider...`

---

#### AuthenticationError(LLMError)

```python
class AuthenticationError(LLMError):
    """Raised when the LLM provider returns a 401 or 403 error."""
```

**Purpose:** Indicates invalid or expired API key — user must fix configuration.

**Invariants:**
- Never retried (immediate failure)
- Always indicates user action required

**Error conditions:**
- HTTP 401/403 → `AuthenticationError: LLM API authentication failed...`

---

#### BriefingGenerationError(LLMError)

```python
class BriefingGenerationError(LLMError):
    def __init__(self, message: str, attempts: int = 1) -> None:
        self.attempts = attempts
        super().__init__(f"{message} (after {attempts} attempt(s))")
```

**Purpose:** Briefing generation failed after all retries.

**Invariants:**
- `attempts` attribute always set
- Message includes attempt count

---

#### MissionGenerationError(LLMError)

```python
class MissionGenerationError(LLMError):
    def __init__(self, message: str, category: str = "", attempts: int = 1) -> None:
        self.category = category
        self.attempts = attempts
```

**Purpose:** Mission generation failed for a specific category.

**Invariants:**
- `category` attribute contains category name (may be empty string)
- `attempts` attribute always set

---

#### TokenBudgetExceededError(LLMError)

```python
class TokenBudgetExceededError(LLMError):
    """Raised when the context exceeds the configured token budget."""
```

**Purpose:** Context too large for provider (future-proofing, currently unused).

---

### Retry Utilities

#### with_llm_retry

```python
def with_llm_retry(
    fn: Callable[..., T],
    *args: object,
    max_attempts: int = 5,
    base_delay: float | None = None,
    provider_name: str | None = None,
    **kwargs: object,
) -> T
```

**Purpose:** Execute a function with exponential backoff retry on transient LLM errors.

**Invariants:**
- Always returns the result of `fn(*args, **kwargs)` on success
- AuthenticationError raised immediately (never retried)
- Delay formula: `base_delay * 2^(attempt-1) + random(0, 1)`

**Valid inputs:**
- `fn`: Any callable
- `max_attempts`: 1-10 (1 = no retries, default 5)
- `base_delay`: Positive float (overrides provider_name if both given)
- `provider_name`: "anthropic" → 1.0s, "glm" → 30.0s, None/unknown → 1.0s

**Error conditions:**
- HTTP 401/403 → `AuthenticationError` (immediate, no retry)
- HTTP 429/5xx → Retried, then original exception after max_attempts
- Connection errors → Retried
- Schema validation error → Retried

**Edge cases:**
- `max_attempts=1` → Executes once, no retry on failure
- Both `base_delay` and `provider_name` given → Uses `base_delay`
- All attempts fail → Raises the last exception

**Delay sequences:**
| base_delay | Attempt 1 → 2 | Attempt 2 → 3 | Attempt 3 → 4 | Attempt 4 → 5 |
|------------|---------------|---------------|---------------|---------------|
| 1.0s | ~1s | ~2s | ~4s | ~8s |
| 30.0s | ~30s | ~60s | ~120s | ~240s |

---

### Context Builder

#### build_briefing_context

```python
def build_briefing_context(
    analysis: AnalysisResult,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
) -> str
```

**Purpose:** Transform static analysis results into structured text context for LLM.

**Invariants:**
- Return string length ≤ `token_budget * 4` characters
- Always includes repository overview section
- Files > 10,000 chars are truncated individually

**Return structure:**
1. `# Repository Overview` — always present
2. `# Entry Points` — if entry_points non-empty
3. `# Module Structure` — if modules non-empty
4. `# Import Graph Summary` — always present
5. `# Test Coverage Summary` — always present
6. `# Security Findings` — if secrets or vulnerabilities exist
7. `# Key File Contents` — budget-aware
8. `# Other Files (Signatures)` — remaining files

**Edge cases:**
- Empty analysis → Returns minimal overview
- Very large codebase → Truncated with `... [context truncated to fit token budget]`

---

## 2. LLM Structured Output

**Module:** `llm/structured.py`

### TruncationResult (Dataclass)

```python
@dataclass
class TruncationResult:
    recovered_data: BaseModel | None
    is_truncated: bool
    last_complete_index: int | None
    resume_hint: str | None
```

**Purpose:** Encapsulate results of parsing LLM response with truncation detection.

**Valid states:**
- Complete response: `(data, False, None, None)`
- Recovered partial: `(data, True, 2, "Continue after 'mission X'...")`
- Nothing recovered: `(None, True, None, "No JSON object found")`
- Empty input: `(None, False, None, None)`

---

### detect_truncation

```python
def detect_truncation(text: str, schema: type[T]) -> TruncationResult
```

**Purpose:** Parse LLM response and detect if JSON was truncated.

**Invariants:**
- Never raises an exception — always returns TruncationResult
- Recovery attempts only for mission-style schemas (has "missions" field)

**Edge cases:**
- Empty text → `TruncationResult(None, False, None, None)`
- Complete JSON → `TruncationResult(parsed, False, None, None)`
- Truncated mid-mission → `TruncationResult(partial, True, last_idx, hint)`
- No JSON at all → `TruncationResult(None, True, None, "No JSON object found")`

---

### create_structured_chain

```python
def create_structured_chain(
    model: BaseChatModel,
    prompt: ChatPromptTemplate,
    schema: type[T],
) -> Runnable
```

**Purpose:** Build a LangChain chain that returns validated Pydantic model instances.

**Provider behavior:**
| Provider | Method |
|----------|--------|
| Anthropic | `model.with_structured_output(schema)` |
| OpenAI | `model.with_structured_output(schema)` |
| GLM (ChatZhipuAI) | Prompt-based JSON + manual parsing |

**Error conditions:**
- JSON parse failure → `ValueError: Could not extract JSON from LLM response...`
- Schema validation failure → `ValueError: LLM response did not match expected schema...`

---

## 3. LLM Providers

**Module:** `llm/providers/factory.py`

### ProviderConfig (Dataclass)

```python
@dataclass(frozen=True)
class ProviderConfig:
    langchain_id: str
    default_model: str
    max_token_budget: int | None = None
    retry_base_delay: float = 1.0
```

**Predefined configurations:**
| Provider | langchain_id | default_model | max_token_budget | retry_base_delay |
|----------|--------------|---------------|------------------|------------------|
| anthropic | "anthropic" | "claude-sonnet-4-5-2025-929" | None | 1.0 |
| glm | "zhipuai" | "glm-4" | 8,000 | 30.0 |

---

### create_chat_model

```python
def create_chat_model(
    config: UnfamiliarConfig,
    max_tokens: int | None = None,
) -> BaseChatModel
```

**Purpose:** Create a configured LangChain chat model from user configuration.

**Invariants:**
- Returns fully initialized BaseChatModel ready for `.invoke()`
- Temperature always set to 0.2
- max_retries always set to 2

**Error conditions:**
- Unknown provider → `ProviderError: Unknown provider: 'xyz'...`
- Missing API key → `ProviderError: No API key found...`

**Edge cases:**
- `UNFAMILIAR_MODEL` env var set → Overrides default model
- `UNFAMILIAR_MAX_TOKENS` env var set → Overrides default max_tokens

---

### get_retry_delay

```python
def get_retry_delay(provider_name: str) -> float
```

**Purpose:** Return the appropriate base delay for retry backoff.

**Expected returns:**
- `"anthropic"` → `1.0`
- `"glm"` → `30.0`
- Unknown → `1.0`

---

### get_effective_token_budget

```python
def get_effective_token_budget(config: UnfamiliarConfig) -> int
```

**Purpose:** Return token budget capped by provider limits.

**Expected returns:**
- Anthropic with budget 100k → `100000` (no cap)
- GLM with budget 100k → `8000` (capped)
- GLM with budget 5000 → `5000` (under cap, unchanged)

---

## 4. LLM Briefing

**Module:** `llm/briefing/`

### ModuleDescription (Pydantic)

```python
class ModuleDescription(BaseModel):
    name: str
    description: str
    security_relevant: bool
```

### BriefingResponse (Pydantic)

```python
class BriefingResponse(BaseModel):
    summary: str          # 3-5 paragraph overview
    tech_stack: list[str] # Technologies used
    security_posture: str # Security assessment
    modules: list[ModuleDescription]
```

### BriefingResult (Dataclass)

```python
@dataclass
class BriefingResult:
    briefing: BriefingResponse
    modules: list[ModuleDescription]
    context_tokens_estimate: int
```

---

### generate_briefing

```python
def generate_briefing(
    analysis: AnalysisResult,
    model: BaseChatModel,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
) -> BriefingResult
```

**Purpose:** Generate a structured codebase briefing via single LLM call.

**Invariants:**
- Makes exactly 1 successful LLM call (plus retries)
- Module count padded to match analysis.modules length

**Error conditions:**
- HTTP 401/403 → `AuthenticationError` (immediate)
- Max retries exhausted → `BriefingGenerationError(attempts=N)`

**Module padding behavior:**
- LLM returns fewer modules than expected → Padded with path-based defaults
- Default: `ModuleDescription(name=primary_path, description="Description not provided by LLM.", security_relevant=False)`

---

## 5. LLM Missions

**Module:** `llm/missions/`

### Pydantic Models

```python
class MissionFileRefResponse(BaseModel):
    file_path: str
    line_start: int | None = None
    line_end: int | None = None
    description: str

class MissionItemResponse(BaseModel):
    title: str                    # Film Noir themed
    title_plain: str              # Plain English (for matching)
    description: str
    difficulty: str               # "quick" | "medium" | "deep_dive"
    completion_condition: str
    security_tag: bool
    standard_tag: str | None = None
    narrative_intro: str
    narrative_outro: str
    file_refs: list[MissionFileRefResponse]
    module_index: int
    prerequisite_titles: list[str] = []

class ChainResponse(BaseModel):
    name: str
    name_plain: str
    description: str
    mission_titles: list[str]

class CategoryMissionsResponse(BaseModel):
    missions: list[MissionItemResponse]
    chains: list[ChainResponse] = []
```

### Dataclasses

```python
@dataclass
class ValidationResult:
    missions: list[MissionItemResponse]
    chains: list[ChainResponse]
    warnings: list[str] = field(default_factory=list)
    fixes_applied: list[str] = field(default_factory=list)
    valid: bool = True

@dataclass
class PipelineResult:
    category_missions: dict[str, CategoryMissionsResponse] = field(default_factory=dict)
    category_validations: dict[str, ValidationResult] = field(default_factory=dict)
    completed_categories: list[str] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)
```

---

### generate_category_missions

```python
def generate_category_missions(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    category: InvestigationCategory,
    model: BaseChatModel,
    previous_missions: list[str] | None = None,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
) -> CategoryMissionsResponse
```

**Purpose:** Generate missions for a single category via one LLM call.

**Invariants:**
- Returns at least 1 mission (or raises MissionGenerationError)
- `previous_missions` for cross-category awareness only

---

### generate_category_missions_multi

```python
def generate_category_missions_multi(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    category: InvestigationCategory,
    model: BaseChatModel,
    previous_missions: list[str] | None = None,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
    missions_per_call: int = 3,
    max_calls: int = 3,
) -> CategoryMissionsResponse
```

**Purpose:** Generate missions using multiple LLM calls with truncation resume.

**Invariants:**
- Makes 1-3 LLM calls depending on truncation
- No duplicate mission titles in result

**Multi-call behavior:**
1. Call 1: Request 3 missions
2. If truncated → Call 2 with "continue from X" hint
3. If still truncated → Call 3
4. Merge all missions, deduplicate by `title_plain`

---

### validate_category_missions

```python
def validate_category_missions(
    missions_response: MissionItemResponse | list[MissionItemResponse],
    repo_path: Path,
    category: InvestigationCategory,
    chains: list[ChainResponse] | None = None,
) -> ValidationResult
```

**Purpose:** Orchestrate all validation passes on generated missions.

**Validation steps (in order):**
1. File refs: Remove invalid paths, clamp line ranges
2. Prerequisites: Remove nonexistent prereqs
3. Cycles: Break circular dependencies
4. Depth: Enforce MAX_PREREQUISITE_DEPTH
5. Roots: Ensure MIN_ROOT_MISSIONS_PER_CATEGORY
6. Reachability: Make unreachable missions into roots
7. Chains: Remove invalid chain references

**Invariants:**
- Always returns ValidationResult (never raises)
- Auto-fixes applied in-place to missions list

---

### validate_file_refs

```python
def validate_file_refs(
    file_refs: list[MissionFileRefResponse],
    repo_path: Path,
) -> tuple[list[MissionFileRefResponse], list[str]]
```

**Purpose:** Validate file references against filesystem.

**Auto-fixes applied:**
- File not found → Removed
- Path traversal → Removed
- `line_start=0` → Fixed to `1`
- `line_start > line_end` → Swapped
- `line_end > file_line_count` → Clamped

**Invariants:**
- Never raises (all errors become warnings)
- All returned refs have valid paths

---

### validate_prerequisites

```python
def validate_prerequisites(
    missions: list[MissionItemResponse],
    category: InvestigationCategory,
) -> tuple[list[MissionItemResponse], list[str], list[str]]
```

**Purpose:** Validate and fix prerequisite relationships.

**Auto-fixes applied:**
1. Remove prereqs referencing nonexistent missions
2. Break cycles by removing one edge
3. Trim prereqs exceeding depth limit
4. Create root missions by removing prereqs
5. Make unreachable missions into roots

**Invariants:**
- No cycles after validation
- At least MIN_ROOT_MISSIONS_PER_CATEGORY roots exist

---

### run_mission_pipeline

```python
def run_mission_pipeline(
    analysis: AnalysisResult,
    briefing_result: BriefingResult,
    model: BaseChatModel,
    campaign_dir: Path,
    token_budget: int = DEFAULT_TOKEN_BUDGET,
    provider_name: str | None = None,
    category_cooldown: float = 0.0,
) -> PipelineResult
```

**Purpose:** Execute the full 7-category mission generation pipeline.

**Category order (CATEGORY_ORDER):**
1. ARCHITECTURE
2. DATA_FLOW
3. DOMAIN_BUSINESS_LOGIC
4. DEPENDENCIES
5. CONVENTIONS
6. TESTING
7. TECHNICAL_DEBT

**Invariants:**
- Categories processed in CATEGORY_ORDER
- Partial results returned on error
- AuthenticationError stops pipeline immediately

**Error handling:**
- Auth error → Stop immediately, return partial results
- MissionGenerationError → Log error, continue to next category
- Validation failure → Store validation result, continue

**Cooldown behavior:**
- `category_cooldown=0.0` → No delays (default)
- `category_cooldown=60.0` → 60s sleep between categories

---

## Key Invariants Summary

### Retry Behavior

| Provider | Base Delay | Delay Sequence (5 attempts) |
|----------|------------|----------------------------|
| anthropic | 1.0s | ~1s → ~2s → ~4s → ~8s → ~16s |
| glm | 30.0s | ~30s → ~60s → ~120s → ~240s → ~480s |

### Token Budgets

| Provider | Max Budget | Notes |
|----------|------------|-------|
| anthropic | Unlimited | Uses config.token_budget |
| glm | 8,000 | Free tier TPM limits |

### Validation Auto-Fixes

| Issue | Fix |
|-------|-----|
| Invalid file path | Remove from file_refs |
| `line_start=0` | Fix to `1` |
| `line_start > line_end` | Swap values |
| Missing prerequisite | Remove from prereqs |
| Prerequisite cycle | Break by removing edge |
| No root missions | Convert low-difficulty to root |
