---
phase: 04-hardening
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/corpus_analyzer/cli.py
  - src/corpus_analyzer/ingest/indexer.py
  - src/corpus_analyzer/mcp/server.py
  - tests/cli/test_search_status.py
  - tests/ingest/test_indexer.py
  - tests/mcp/test_server.py
autonomous: true
requirements:
  - CLI-02
  - INGEST-07
  - MCP-02

must_haves:
  truths:
    - "`corpus search` never raises KeyError when `_relevance_score` is absent from a result row"
    - "Stale chunk deletion failure logs a warning instead of silently passing"
    - "File scan failure in `_get_existing_files` logs a warning instead of silently returning empty dict"
    - "MCP `corpus_search` tool returns `content_error` field when a file cannot be read post-indexing"
    - "MCP `content_error` key is absent (not null, not empty string) when file read succeeds"
  artifacts:
    - path: "src/corpus_analyzer/cli.py"
      provides: "Safe score access via .get() with 0.0 fallback"
      contains: "result.get('_relevance_score', 0.0)"
    - path: "src/corpus_analyzer/ingest/indexer.py"
      provides: "Warning logs in both silent exception handlers"
      contains: "logging.warning"
    - path: "src/corpus_analyzer/mcp/server.py"
      provides: "content_error field on OSError + warning log"
      contains: "content_error"
    - path: "tests/cli/test_search_status.py"
      provides: "Test: missing _relevance_score does not raise"
    - path: "tests/ingest/test_indexer.py"
      provides: "Tests: warning logged on _delete_stale_chunks and _get_existing_files failure"
    - path: "tests/mcp/test_server.py"
      provides: "Tests: content_error present on OSError, absent on success"
  key_links:
    - from: "src/corpus_analyzer/cli.py"
      to: "result dict"
      via: "dict.get() with 0.0 default"
      pattern: "result\\.get\\('_relevance_score', 0\\.0\\)"
    - from: "src/corpus_analyzer/ingest/indexer.py"
      to: "logging.warning"
      via: "except Exception as exc"
      pattern: "logging\\.warning.*source_name.*exc"
    - from: "src/corpus_analyzer/mcp/server.py"
      to: "result_dict"
      via: "conditional key insertion"
      pattern: "if content_error is not None"
---

<objective>
Close the three critical safety gaps identified in the v1.0 audit using TDD cycles: a KeyError crash risk in CLI search output (CLI-02), silent exception swallowing in two indexer methods (INGEST-07), and a silent OSError in the MCP server's full_content reader (MCP-02).

Purpose: These are the three phase requirements in their hardening dimension. Closing them ensures `corpus search` never crashes on missing score fields, stale-file failures surface as warnings instead of silent data loss, and MCP clients receive an error signal when a file cannot be read post-indexing.
Output: Three targeted fixes across three files, each covered by new failing-then-passing tests. No interface signatures change.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-hardening/04-CONTEXT.md
@.planning/phases/04-hardening/04-RESEARCH.md
</context>

<tasks>

<task type="auto">
  <name>Task 1: Fix CLI-02 KeyError with TDD</name>
  <files>src/corpus_analyzer/cli.py
tests/cli/test_search_status.py</files>
  <action>
    RED-GREEN-REFACTOR cycle for the `_relevance_score` KeyError.

    **RED:** Add test `test_search_result_missing_relevance_score_does_not_raise` to `tests/cli/test_search_status.py`.
    Mock `CorpusSearch.hybrid_search` to return a result dict containing `file_path`, `text`, and `construct_type` but NOT `_relevance_score`. Invoke the CLI search command via `typer.testing.CliRunner`. Assert the exit code is 0 (no crash). Run the test — it MUST fail with KeyError.

    **GREEN:** Change cli.py at the score display line (currently `result['_relevance_score']`):
    ```python
    # Before (KeyError risk):
    f"[dim]score: {result['_relevance_score']:.3f}[/]"
    # After (safe):
    f"[dim]score: {result.get('_relevance_score', 0.0):.3f}[/]"
    ```
    This is a 1-character change: `[` → `.get(` and `]` → `, 0.0)`. Run test — must now pass.

    **REFACTOR:** None needed for a 1-line fix.

    Consistency note: `mcp/server.py:91` and `api/public.py:113` already use `.get('_relevance_score', 0.0)`. This change makes CLI consistent with the other two interfaces (per RESEARCH.md Pattern 3).
  </action>
  <verify>
    <automated>uv run pytest tests/cli/test_search_status.py -x -q</automated>
    <manual>Confirm `result['_relevance_score']` no longer appears in cli.py (use grep to check)</manual>
    <sampling_rate>run after this task commits, before next task begins</sampling_rate>
  </verify>
  <done>New test `test_search_result_missing_relevance_score_does_not_raise` passes; `result.get('_relevance_score', 0.0)` in cli.py; all CLI tests pass</done>
</task>

<task type="auto">
  <name>Task 2: Fix INGEST-07 silent swallows with TDD</name>
  <files>src/corpus_analyzer/ingest/indexer.py
tests/ingest/test_indexer.py</files>
  <action>
    RED-GREEN-REFACTOR cycle for both bare except-blocks in indexer.py.

    **RED:** Add two tests to `tests/ingest/test_indexer.py`:
    - `test_get_existing_files_logs_warning_on_exception`: patch `self._table.search` (via monkeypatch or `unittest.mock.patch`) to raise `RuntimeError("db error")`. Use `caplog` with `caplog.set_level(logging.WARNING)` to capture log output. Call `index._get_existing_files("my-source")`. Assert `"my-source"` appears in at least one captured warning message.
    - `test_delete_stale_chunks_logs_warning_on_exception`: same approach — patch table.search to raise, call `index._delete_stale_chunks("my-source", set())`, assert warning message contains "my-source".
    Run both tests — they MUST fail (no warning emitted yet, logging not imported).

    **GREEN:** In `indexer.py`:
    1. Add `import logging` to the import block. Place it with the stdlib imports (after `import time`, following ruff isort rules — stdlib before third-party). The existing import order is: `time`, `collections.abc`, `dataclasses`, `datetime`, `pathlib` — add `logging` alphabetically among them.
    2. Replace `_get_existing_files` except block (~lines 299-301):
       ```python
       # Before:
       except Exception:
           # Table might be empty or query failed
           return {}
       # After:
       except Exception as exc:
           logging.warning("Failed to query existing files for source '%s': %s", source_name, exc)
           return {}
       ```
    3. Replace `_delete_stale_chunks` except block (~lines 353-355):
       ```python
       # Before:
       except Exception:
           # If deletion fails, continue (not critical)
           pass
       # After:
       except Exception as exc:
           logging.warning("Failed to delete stale chunks for source '%s': %s", source_name, exc)
       ```
    Use `%`-style lazy formatting (not f-strings) — consistent with `mcp/server.py` convention and Python logging best practice.

    **REFACTOR:** Remove the now-stale inline comments ("Table might be empty or query failed", "If deletion fails, continue (not critical)") since the warning log communicates the intent more clearly.

    Note: `_get_stored_summaries()` also has a bare `except Exception: return {}` around line 319. It is not in the audit scope, but adding a warning there too is consistent. Check if it is truly bare — if so, add a warning log with the same pattern.
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_indexer.py -x -q</automated>
    <manual>Confirm `import logging` is at the top of indexer.py and both bare `except Exception:` blocks now log warnings</manual>
    <sampling_rate>run after this task commits, before next task begins</sampling_rate>
  </verify>
  <done>Two new warning tests pass; `import logging` in indexer.py; both except blocks emit `logging.warning` with source name; no regressions in ingest tests</done>
</task>

<task type="auto">
  <name>Task 3: Fix MCP-02 silent OSError with TDD</name>
  <files>src/corpus_analyzer/mcp/server.py
tests/mcp/test_server.py</files>
  <action>
    RED-GREEN-REFACTOR cycle for the silent OSError in MCP full_content reading.

    **RED:** Add two tests to `tests/mcp/test_server.py` (follow the existing mock pattern from 03-03 plans):
    - `test_corpus_search_content_error_present_on_oserror`: mock `Path.read_text` to raise `OSError("no such file")`. Call the `corpus_search` tool logic with a mocked engine returning one result row. Assert the returned result dict contains key `"content_error"` with a non-empty string value.
    - `test_corpus_search_content_error_absent_on_success`: mock `Path.read_text` to return `"file content"` normally. Assert the returned result dict does NOT contain key `"content_error"`.
    Run both tests — they MUST fail (`content_error` is never set currently).

    **GREEN:** Replace the per-row file-read block in `server.py` (lines ~85-88) with the conditional pattern from RESEARCH.md:
    ```python
    content_error: str | None = None
    try:
        full_content = Path(file_path).read_text(errors="replace")
    except OSError as exc:
        logging.warning("Cannot read file after indexing: %s — %s", file_path, exc)
        full_content = ""
        content_error = f"File not found: {file_path}"

    result_dict: dict[str, Any] = {
        "path": file_path,
        "score": float(row.get("_relevance_score", 0.0)),
        "snippet": extract_snippet(str(row.get("text", "")), query),
        "full_content": full_content,
        "construct_type": str(row.get("construct_type") or "documentation"),
        "summary": str(row.get("summary") or ""),
        "file_type": str(row.get("file_type", "")),
    }
    if content_error is not None:
        result_dict["content_error"] = content_error
    results.append(result_dict)
    ```
    Note: `logging` is already imported at the top of `server.py` — no new import needed.
    Note: Keep `full_content = ""` on OSError (do NOT change to `None`) — changing it would break existing MCP clients that handle the current response shape.
    Note: The `content_error` key must be ABSENT (not `None`, not `""`) on success — the `if content_error is not None:` guard ensures this.

    **REFACTOR:** None needed.

    After fixing server.py, run the full test suite to confirm no regressions.
  </action>
  <verify>
    <automated>uv run pytest tests/mcp/test_server.py -x -q && uv run pytest -x -q</automated>
    <manual>Check that the success-path test asserts `"content_error" not in result` (key absent, not null)</manual>
    <sampling_rate>run both commands sequentially — targeted tests then full suite</sampling_rate>
  </verify>
  <done>Two new tests pass (`content_error` present on OSError, absent on success); full suite passes with no regressions; mypy and ruff clean on server.py</done>
</task>

</tasks>

<verification>
Run all three targeted test files plus full suite and type/lint checks:

```bash
uv run pytest tests/cli/test_search_status.py tests/ingest/test_indexer.py tests/mcp/test_server.py -v
uv run pytest -x -q
uv run mypy src/corpus_analyzer/cli.py src/corpus_analyzer/ingest/indexer.py src/corpus_analyzer/mcp/server.py
uv run ruff check src/corpus_analyzer/cli.py src/corpus_analyzer/ingest/indexer.py src/corpus_analyzer/mcp/server.py
```

All must pass clean.
</verification>

<success_criteria>
1. `result['_relevance_score']` no longer appears in cli.py — replaced with `.get('_relevance_score', 0.0)`
2. `import logging` present in indexer.py; both bare `except Exception:` blocks replaced with `except Exception as exc:` + `logging.warning(...)` using `%`-style formatting
3. MCP server result dict includes `content_error` field (string) on OSError; key is absent on success
4. All three new test groups pass; full test suite passes with no regressions
5. mypy and ruff pass clean on all three modified source files
</success_criteria>

<output>
After completion, create `.planning/phases/04-hardening/04-01-SUMMARY.md` using the summary template.
</output>
