lore drift: detect discussion divergence from original intent

## Background
Detect when a discussion thread has evolved away from the original issue description. Surfaces hidden scope creep. No existing tool does this — not GitLab, Jira, Linear, or any CLI.

## Current Infrastructure (Verified 2026-02-12)
- Embeddings: nomic-embed-text model, 768 dimensions, stored in embedding_metadata + vec0 tables
- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding
- notes table: 282K rows with body, author, created_at, is_system, discussion_id
- issues table: description column contains original intent text
- CHUNK_MAX_BYTES = 1500 bytes for embedding input
- No `strip_markdown()` utility exists in the codebase — must be written (see Edge Cases)

## Dependencies
This command is standalone. It only requires:
- OllamaClient (already shipped at src/embedding/ollama.rs) for embedding computation
- notes + discussions tables (already in DB since migration 001/004)
- issues table (already in DB since migration 002)

No dependency on hybrid search (bd-1ksf) or per-note search (bd-2l3s). Drift embeds on-the-fly.

## Algorithm

### Step 1: Embed issue description
```rust
let desc_text = issue.description.unwrap_or_default();
if desc_text.len() < 20 {
    // Too short for meaningful drift analysis
    return Ok(DriftResponse::no_drift("Description too short for analysis"));
}
let desc_embedding = client.embed_batch(&[&desc_text]).await?[0].clone();
```

### Step 2: Get non-system notes chronologically
```sql
SELECT n.id, n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue' AND d.noteable_id = ?
  AND n.is_system = 0
  AND LENGTH(n.body) >= 20
ORDER BY n.created_at ASC
LIMIT 200  -- cap for performance
```

### Step 3: Embed each note
```rust
let note_texts: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();
// Batch in groups of 32 (BATCH_SIZE from embedding pipeline)
let note_embeddings = client.embed_batch(&note_texts).await?;
```

### Step 4: Compute cosine similarity curve
```rust
/// Cosine similarity between two embedding vectors.
/// Returns value in [-1, 1] range; higher = more similar.
/// Place in src/embedding/similarity.rs for reuse by related (bd-8con) and drift.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    debug_assert_eq!(a.len(), b.len(), "embedding dimensions must match");
    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm_a == 0.0 || norm_b == 0.0 {
        return 0.0;  // zero vector = no similarity
    }
    dot / (norm_a * norm_b)
}

let similarity_curve: Vec<SimilarityPoint> = notes.iter().zip(&note_embeddings)
    .enumerate()
    .map(|(i, (note, emb))| SimilarityPoint {
        note_index: i,
        note_id: note.id,
        similarity: cosine_similarity(&desc_embedding, emb),
        author: note.author.clone(),
        created_at: note.created_at.clone(),
    })
    .collect();
```

### Step 5: Detect drift via sliding window
```rust
const DEFAULT_THRESHOLD: f32 = 0.4;
const WINDOW_SIZE: usize = 3;

fn detect_drift(curve: &[SimilarityPoint], threshold: f32) -> Option<&SimilarityPoint> {
    if curve.len() < WINDOW_SIZE {
        return None;  // need minimum 3 notes for window
    }
    for window in curve.windows(WINDOW_SIZE) {
        let avg: f32 = window.iter().map(|p| p.similarity).sum::<f32>() / WINDOW_SIZE as f32;
        if avg < threshold {
            return Some(&window[0]);  // first note in drifting window
        }
    }
    None
}
```

### Step 6: Extract drift topics (simple term frequency v1)
```rust
/// Simple markdown stripping for embedding quality.
/// Remove code blocks (```...```), inline code (`...`), links [text](url),
/// block quotes (> ...), and HTML tags (<...>).
/// This function must be written — no existing utility in the codebase.
fn strip_markdown(text: &str) -> String {
    // Phase 1: Remove fenced code blocks (```...```)
    let re_code_block = regex::Regex::new(r"(?s)```.*?```").unwrap();
    let text = re_code_block.replace_all(text, "");
    // Phase 2: Remove inline code (`...`)
    let re_inline = regex::Regex::new(r"`[^`]+`").unwrap();
    let text = re_inline.replace_all(&text, "");
    // Phase 3: Remove markdown links, keep text: [text](url) -> text
    let re_link = regex::Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
    let text = re_link.replace_all(&text, "$1");
    // Phase 4: Remove block quotes
    let text = text.lines()
        .filter(|l| !l.trim_start().starts_with('>'))
        .collect::<Vec<_>>()
        .join("\n");
    // Phase 5: Remove HTML tags
    let re_html = regex::Regex::new(r"<[^>]+>").unwrap();
    re_html.replace_all(&text, "").to_string()
}

fn extract_drift_topics(
    notes_after_drift: &[Note],
    description_words: &HashSet<String>,
) -> Vec<String> {
    let stopwords: HashSet<&str> = [
        "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
        "have", "has", "had", "do", "does", "did", "will", "would", "could",
        "should", "may", "might", "shall", "can", "need", "dare", "ought",
        "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
        "as", "into", "through", "during", "before", "after", "above", "below",
        "between", "out", "off", "over", "under", "again", "further", "then",
        "once", "here", "there", "when", "where", "why", "how", "all", "each",
        "every", "both", "few", "more", "most", "other", "some", "such", "no",
        "nor", "not", "only", "own", "same", "so", "than", "too", "very",
        "just", "because", "but", "and", "or", "if", "while", "that", "this",
        "these", "those", "it", "its", "they", "them", "their", "we", "our",
        "you", "your", "he", "she", "his", "her", "what", "which", "who",
    ].into_iter().collect();

    let mut term_freq: HashMap<String, usize> = HashMap::new();
    for note in notes_after_drift {
        let body = strip_markdown(&note.body);
        for word in body.split_whitespace() {
            let word = word.to_lowercase()
                .trim_matches(|c: char| !c.is_alphanumeric())
                .to_string();
            if word.len() >= 3
                && !stopwords.contains(word.as_str())
                && !description_words.contains(&word)
            {
                *term_freq.entry(word).or_default() += 1;
            }
        }
    }

    let mut ranked: Vec<_> = term_freq.into_iter().collect();
    ranked.sort_by(|a, b| b.1.cmp(&a.1));
    ranked.into_iter().take(3).map(|(word, _)| word).collect()
}
```

NOTE: The `regex` crate is likely already a dependency (check Cargo.toml). If not, add it. Consider compiling regexes once with `lazy_static!` or `std::sync::LazyLock` instead of in-function `Regex::new()`.

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "entity": { "type": "issue", "iid": 3864, "title": "..." },
    "drift_detected": true,
    "threshold": 0.4,
    "drift_point": {
      "note_index": 12,
      "note_id": 456,
      "author": "devname",
      "created_at": "2026-01-20T...",
      "similarity": 0.32
    },
    "drift_topics": ["ingestion", "maintenance", "lubrication"],
    "similarity_curve": [
      { "note_index": 0, "similarity": 0.91, "author": "...", "created_at": "..." },
      { "note_index": 1, "similarity": 0.85, "author": "...", "created_at": "..." }
    ],
    "recommendation": "Consider splitting: notes after #12 discuss ingestion, maintenance, lubrication -- topics not in original description"
  },
  "meta": { "elapsed_ms": 1500, "notes_analyzed": 25, "description_tokens": 150 }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Drift {
    /// Entity type: "issues" (MRs not supported in v1)
    entity_type: String,
    /// Entity IID
    iid: i64,
    /// Similarity threshold for drift detection (0.0-1.0, default 0.4)
    #[arg(long, default_value = "0.4")]
    threshold: f32,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/drift.rs:
- test_cosine_similarity_identical: same vector -> 1.0
- test_cosine_similarity_orthogonal: orthogonal vectors -> 0.0
- test_cosine_similarity_zero_vector: zero vector -> 0.0 (not NaN)
- test_drift_detected_when_notes_diverge: mock embeddings where first 5 notes are similar (>0.8) to desc, last 5 are dissimilar (<0.3), assert drift_detected=true
- test_no_drift_on_consistent_discussion: all notes similar to desc (>0.6), assert drift_detected=false
- test_drift_point_is_first_divergent: assert drift_point.note_index is the first note in the first sub-threshold window
- test_drift_topics_exclude_original_terms: terms from description body should NOT appear in drift_topics
- test_single_note: assert drift_detected=false (need min 3 notes)
- test_empty_description: assert response with "Description too short for analysis" message
- test_strip_markdown_code_blocks: verify fenced code blocks removed
- test_strip_markdown_preserves_text: verify plain text preserved

GREEN: Implement drift command with cosine_similarity + sliding window + topic extraction

VERIFY:
```bash
cargo test drift:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J drift issues 3864 | jq '.data.drift_detected'
```

## Acceptance Criteria
- [ ] lore drift issues N computes similarity curve between description and notes
- [ ] Drift detected when sliding window of 3 notes averages below threshold
- [ ] Drift topics extracted from divergent notes (top 3 terms not in description)
- [ ] --threshold flag to adjust sensitivity (default 0.4)
- [ ] Robot mode returns structured analysis with similarity_curve array
- [ ] Human mode shows visual indication (similarity bar or sparkline per note)
- [ ] Suggests splitting when drift detected
- [ ] Performance: <2s for issue with 100 notes (mostly embedding time)
- [ ] Command registered in main.rs and robot-docs
- [ ] cosine_similarity function has its own unit tests
- [ ] strip_markdown function has its own unit tests

## Edge Cases
- Empty description: return early with message "Description too short for analysis"
- Single note: drift_detected = false, similarity_curve has 1 entry
- Very short notes (<20 chars): filtered out in SQL query
- All notes by same author: still valid analysis (self-drift is real)
- Notes that are mostly quotes/code blocks: strip_markdown before embedding (remove ``` blocks, > quotes)
- Issue with 500+ notes: SQL LIMIT 200 on notes, note in meta that analysis is partial
- Ollama unavailable: exit code 14 with message (drift requires embedding computation)
- No stored note embeddings: always embed on-the-fly (drift needs to compare against description, not stored embeddings)
- Embedding dimension mismatch: assert desc and note embeddings have same length (768 for nomic-embed-text)
- Regex compilation: use LazyLock or lazy_static to avoid recompiling regexes on every call

## Files to Create/Modify
- NEW: src/cli/commands/drift.rs (main command implementation)
- NEW: src/embedding/similarity.rs (cosine_similarity utility, reusable by bd-8con)
- src/embedding/mod.rs (export similarity module)
- src/cli/commands/mod.rs (add pub mod drift; re-export)
- src/main.rs (register Drift subcommand in Commands enum, add handle_drift fn)

Drift detection command implemented: cosine similarity curve, sliding window, topic extraction, human+robot output


Epic: TUI Phase 2 — Core Screens

## Background
Phase 2 implements the five core screens: Dashboard, Issue List, Issue Detail, MR List, and MR Detail. These screens cover the primary read workflows. Each screen has a state struct, view function, and action query bridge. The entity table and filter bar widgets are shared across list screens.

## Acceptance Criteria
- [ ] Dashboard renders project overview with stats, recent activity, sync status
- [ ] Issue List supports keyset pagination, filtering, sorting, and Quick Peek
- [ ] Issue Detail shows progressive hydration (metadata, discussions, cross-refs)
- [ ] MR List mirrors Issue List patterns with MR-specific columns
- [ ] MR Detail shows file changes, diff discussions, and general discussions
- [ ] All screens use TaskSupervisor for data loading with stale-result guards
- [ ] Navigation between screens preserves state

## Scope
Blocked by Phase 1 (Foundation). Blocks Phase 2.5 (Vertical Slice Gate).


[CP1] GitLab client pagination methods

Add async generator methods for paginated GitLab API calls.

Methods to add to src/gitlab/client.ts:
- paginateIssues(gitlabProjectId, updatedAfter?) → AsyncGenerator<GitLabIssue>
- paginateIssueDiscussions(gitlabProjectId, issueIid) → AsyncGenerator<GitLabDiscussion>
- requestWithHeaders<T>(path) → { data: T, headers: Headers }

Implementation:
- Use scope=all, state=all for issues
- Order by updated_at ASC
- Follow X-Next-Page header until empty/absent
- Apply cursor rewind (subtract cursorRewindSeconds) for tuple semantics
- Fall back to empty-page detection if headers missing

Files: src/gitlab/client.ts
Tests: tests/unit/pagination.test.ts
Done when: Pagination handles multiple pages and respects cursors


Implement discussion tree + cross-reference widgets

## Background
The discussion tree renders threaded conversations from GitLab issues/MRs using FrankenTUI's Tree widget. Cross-references show linked entities (closing MRs, related issues) as navigable links. Both are used in Issue Detail and MR Detail views.

## Approach
Discussion Tree (view/common/discussion_tree.rs):
- Wraps ftui Tree widget with TreePersistState for expand/collapse persistence
- Tree structure: top-level discussions as roots, notes within discussion as children
- Each node renders: author, timestamp (relative via Clock), note body (sanitized)
- System notes rendered with muted style
- Diff notes show file path + line reference
- Keyboard: j/k navigate, Enter expand/collapse, Space toggle thread
- Expand-on-demand: thread bodies loaded only when expanded (progressive hydration phase 3)

Cross-Reference (view/common/cross_ref.rs):
- CrossRefWidget: renders list of entity references with type icon and navigable links
- CrossRef struct: kind (ClosingMR, RelatedIssue, MentionedIn), entity_key (EntityKey), label (String)
- Enter on a cross-ref navigates to that entity (pushes nav stack)
- Renders as: "Closing MR !42: Fix authentication flow" with colored kind indicator

## Acceptance Criteria
- [ ] Discussion tree renders top-level discussions as expandable nodes
- [ ] Notes within discussion shown as children with indentation
- [ ] System notes visually distinguished (muted color)
- [ ] Diff notes show file path context
- [ ] Timestamps use injected Clock for deterministic rendering
- [ ] All note text sanitized via sanitize_for_terminal()
- [ ] Cross-references render with entity type icons
- [ ] Enter on cross-ref navigates to entity detail
- [ ] Tree state persists across navigation (expand/collapse remembered)

## Files
- CREATE: crates/lore-tui/src/view/common/discussion_tree.rs
- CREATE: crates/lore-tui/src/view/common/cross_ref.rs

## TDD Anchor
RED: Write test_cross_ref_entity_key that creates a CrossRef with EntityKey::mr(1, 42), asserts kind and key are correct.
GREEN: Implement CrossRef struct.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_cross_ref

## Edge Cases
- Deeply nested discussions (rare in GitLab but possible): limit indent depth to 4 levels
- Very long note bodies: wrap text within tree node area
- Empty discussions (resolved with no notes): show "[resolved]" indicator
- Cross-references to entities not in local DB: show as non-navigable text

## Dependency Context
Uses sanitize_for_terminal() from "Implement terminal safety module" task.
Uses Clock for timestamps from "Implement Clock trait" task.
Uses EntityKey, Screen from "Implement core types" task.
Uses NavigationStack from "Implement NavigationStack" task.


Epic: TUI Phase 4 — Operations

## Background
Phase 4 adds operational screens: Sync (real-time progress + post-sync summary), Doctor/Stats (health checks), and CLI integration (lore tui command for binary delegation). The Sync screen is the most complex — it needs real-time streaming progress with backpressure handling.

## Acceptance Criteria
- [ ] Sync screen shows real-time progress during sync with per-lane indicators
- [ ] Sync summary shows exact changed entities after completion
- [ ] Doctor screen shows environment health checks
- [ ] Stats screen shows database statistics
- [ ] CLI integration: lore tui launches lore-tui binary via runtime delegation


Implement run_embed_for_document_ids scoped embedding

## Background

Currently `embed_documents()` in `src/embedding/pipeline.rs` uses `find_pending_documents()` to discover ALL documents that need embedding (no existing embedding, changed content_hash, or model mismatch). The surgical sync pipeline needs a scoped variant that only embeds specific document IDs — the ones returned by the scoped doc regeneration step (bd-hs6j).

The existing `embed_page()` private function handles the actual embedding work for a batch of `PendingDocument` structs. It calls `split_into_chunks`, sends batches to the OllamaClient, and writes embeddings + metadata to the DB. The scoped function can reuse this by constructing `PendingDocument` structs from the provided document IDs.

Key types:
- `PendingDocument { document_id: i64, content_text: String, content_hash: String }` (from `change_detector.rs`)
- `EmbedResult { chunks_embedded, docs_embedded, failed, skipped }` (pipeline.rs:21)
- `OllamaClient` for the actual embedding API calls
- `ShutdownSignal` for cancellation support

## Approach

Add `embed_documents_by_ids()` to `src/embedding/pipeline.rs`:

```rust
pub struct EmbedForIdsResult {
    pub chunks_embedded: usize,
    pub docs_embedded: usize,
    pub failed: usize,
    pub skipped: usize,
}

pub async fn embed_documents_by_ids(
    conn: &Connection,
    client: &OllamaClient,
    model_name: &str,
    concurrency: usize,
    document_ids: &[i64],
    signal: &ShutdownSignal,
) -> Result<EmbedForIdsResult>
```

Implementation:
1. If `document_ids` is empty, return immediately with zero counts.
2. Load `PendingDocument` structs for the specified IDs. Query: `SELECT id, content_text, content_hash FROM documents WHERE id IN (...)`. Filter out documents that already have current embeddings (same content_hash, model, dims, chunk_max_bytes) — reuse the LEFT JOIN logic from `find_pending_documents` but with `WHERE d.id IN (?)` instead of `WHERE d.id > ?`.
3. If no documents need embedding after filtering, return with skipped=len.
4. Chunk into pages of `DB_PAGE_SIZE` (500).
5. For each page, call `embed_page()` (reuse existing private function) within a SAVEPOINT.
6. Handle cancellation via `signal.is_cancelled()` between pages.

Alternative simpler approach: load all specified doc IDs into a temp table or use a parameterized IN clause, then let `embed_page` process them. Since the list is typically small (1-5 documents for surgical sync), a single page call suffices.

Export from `src/embedding/mod.rs` if not already pub.

## Acceptance Criteria

- [ ] `embed_documents_by_ids` only embeds the specified document IDs, not all pending documents
- [ ] Documents already embedded with current content_hash + model are skipped (not re-embedded)
- [ ] Empty document_ids input returns immediately with zero counts
- [ ] Cancellation via ShutdownSignal is respected between pages
- [ ] SAVEPOINT/ROLLBACK semantics match existing `embed_documents` for data integrity
- [ ] Ollama errors for individual documents are counted as failed, not fatal
- [ ] Function is pub for use by orchestration (bd-1i4i)

## Files

- `src/embedding/pipeline.rs` (add new function + result struct)
- `src/embedding/mod.rs` (export if needed)

## TDD Anchor

Tests in `src/embedding/pipeline_tests.rs` (or new `src/embedding/scoped_embed_tests.rs`):

```rust
#[tokio::test]
async fn test_embed_by_ids_only_embeds_specified_docs() {
    let conn = setup_test_db_with_documents();
    let mock = MockServer::start().await;
    setup_ollama_mock(&mock).await;
    let client = OllamaClient::new(&mock.uri());

    // Insert 2 documents: A (id=1) and B (id=2)
    insert_test_document(&conn, 1, "Content A", "hash_a");
    insert_test_document(&conn, 2, "Content B", "hash_b");

    let signal = ShutdownSignal::new();
    let result = embed_documents_by_ids(
        &conn, &client, "nomic-embed-text", 1,
        &[1],  // Only embed doc 1
        &signal,
    ).await.unwrap();

    assert_eq!(result.docs_embedded, 1);
    // Verify doc 1 has embeddings
    let count: i64 = conn.query_row(
        "SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 1",
        [], |r| r.get(0),
    ).unwrap();
    assert!(count > 0);
    // Verify doc 2 has NO embeddings
    let count_b: i64 = conn.query_row(
        "SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 2",
        [], |r| r.get(0),
    ).unwrap();
    assert_eq!(count_b, 0);
}

#[tokio::test]
async fn test_embed_by_ids_skips_already_embedded() {
    let conn = setup_test_db_with_documents();
    let mock = MockServer::start().await;
    setup_ollama_mock(&mock).await;
    let client = OllamaClient::new(&mock.uri());

    insert_test_document(&conn, 1, "Content A", "hash_a");
    let signal = ShutdownSignal::new();

    // Embed once
    embed_documents_by_ids(&conn, &client, "nomic-embed-text", 1, &[1], &signal).await.unwrap();
    // Embed again with same hash — should skip
    let result = embed_documents_by_ids(
        &conn, &client, "nomic-embed-text", 1, &[1], &signal,
    ).await.unwrap();
    assert_eq!(result.docs_embedded, 0);
    assert_eq!(result.skipped, 1);
}

#[tokio::test]
async fn test_embed_by_ids_empty_input() {
    let conn = setup_test_db_with_documents();
    let mock = MockServer::start().await;
    let client = OllamaClient::new(&mock.uri());
    let signal = ShutdownSignal::new();

    let result = embed_documents_by_ids(
        &conn, &client, "nomic-embed-text", 1, &[], &signal,
    ).await.unwrap();
    assert_eq!(result.docs_embedded, 0);
    assert_eq!(result.chunks_embedded, 0);
}

#[tokio::test]
async fn test_embed_by_ids_respects_cancellation() {
    let conn = setup_test_db_with_documents();
    let mock = MockServer::start().await;
    // Use delayed response to allow cancellation
    setup_slow_ollama_mock(&mock).await;
    let client = OllamaClient::new(&mock.uri());

    insert_test_document(&conn, 1, "Content A", "hash_a");
    let signal = ShutdownSignal::new();
    signal.cancel();  // Pre-cancel

    let result = embed_documents_by_ids(
        &conn, &client, "nomic-embed-text", 1, &[1], &signal,
    ).await.unwrap();
    assert_eq!(result.docs_embedded, 0);
}
```

## Edge Cases

- Document ID that does not exist in the documents table: query returns no rows, skipped silently.
- Document with empty `content_text`: `split_into_chunks` may return 0 chunks, counted as skipped.
- Ollama server unreachable: returns `OllamaUnavailable` error. Must not leave partial embeddings (SAVEPOINT rollback).
- Very long document (>1500 bytes): gets chunked into multiple chunks by `split_into_chunks`. All chunks for one document must be embedded atomically.
- Document already has embeddings but with different model: content_hash check passes but model mismatch detected — should re-embed.
- Concurrent calls with overlapping document_ids: SAVEPOINT isolation prevents conflicts, last writer wins on embedding_metadata upsert.

## Dependency Context

- **Blocked by bd-hs6j**: Gets `document_ids` from scoped doc regeneration output
- **Blocks bd-1i4i**: Orchestration function calls this as the final step of surgical sync
- **Blocks bd-3jqx**: Integration tests verify embed isolation (only surgical docs get embedded)
- **Uses existing internals**: `embed_page`, `PendingDocument`, `split_into_chunks`, `OllamaClient`, `ShutdownSignal`

Completed: all implementation work done, code reviewed, tests passing


Wire resource event fetching into sync pipeline

## Background
After issue/MR primary ingestion and discussion fetch, changed entities need resource_events jobs enqueued and drained. This is the integration point that connects the queue (bd-tir), API client (bd-sqw), DB upserts (bd-1uc), and config flag (bd-2e8).

## Approach
Modify the sync pipeline to add two new phases after discussion sync:

**Phase 1 — Enqueue during ingestion:**
In src/ingestion/orchestrator.rs, after each entity upsert (issue or MR), call:
```rust
if config.sync.fetch_resource_events {
    enqueue_job(conn, project_id, "issue", iid, local_id, "resource_events", None)?;
}
// For MRs, also enqueue mr_closes_issues (always) and mr_diffs (when fetchMrFileChanges)
```

The "changed entity" detection uses the existing dirty tracker: if an entity was inserted or updated during this sync run, it gets enqueued. On --full sync, all entities are enqueued.

**Phase 2 — Drain dependent queue:**
Add a new drain step in src/cli/commands/sync.rs (or new src/core/drain.rs), called after discussion sync:
```rust
pub async fn drain_dependent_queue(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    progress: Option<ProgressCallback>,
) -> Result<DrainResult>
```

Flow:
1. reclaim_stale_locks(conn, config.sync.stale_lock_minutes)
2. Loop: claim_jobs(conn, "resource_events", batch_size=10)
3. For each job:
   a. Fetch 3 event types via client (fetch_issue_state_events etc.)
   b. Store via upsert functions (upsert_state_events etc.)
   c. complete_job(conn, job.id) on success
   d. fail_job(conn, job.id, error_msg) on failure
4. Report progress: "Fetching resource events... [N/M]"
5. Repeat until no more claimable jobs

**Progress reporting:**
Add new ProgressEvent variants:
```rust
ResourceEventsFetchStart { total: usize },
ResourceEventsFetchProgress { completed: usize, total: usize },
ResourceEventsFetchComplete { fetched: usize, failed: usize },
```

## Acceptance Criteria
- [ ] Full sync enqueues resource_events jobs for all issues and MRs
- [ ] Incremental sync only enqueues for entities changed since last sync
- [ ] --no-events prevents enqueueing resource_events jobs
- [ ] Drain step fetches all 3 event types per entity
- [ ] Successful fetches stored and job completed
- [ ] Failed fetches recorded with error, job retried on next sync
- [ ] Stale locks reclaimed at drain start
- [ ] Progress displayed: "Fetching resource events... [N/M]"
- [ ] Robot mode progress suppressed (quiet mode)

## Files
- src/ingestion/orchestrator.rs (add enqueue calls during upsert)
- src/cli/commands/sync.rs (add drain step after discussions)
- src/core/drain.rs (new, optional — or inline in sync.rs)

## TDD Loop
RED: tests/sync_pipeline_tests.rs (or extend existing):
- `test_sync_enqueues_resource_events_for_changed_entities` - mock sync, verify jobs enqueued
- `test_sync_no_events_flag_skips_enqueue` - verify no jobs when flag false
- `test_drain_completes_jobs_on_success` - mock API responses, verify jobs deleted
- `test_drain_fails_jobs_on_error` - mock API failure, verify job attempts incremented

Note: Full pipeline integration tests may need mock HTTP server. Start with unit tests on enqueue/drain logic using the real DB with mock API responses.

GREEN: Implement enqueue hooks + drain step

VERIFY: `cargo test sync -- --nocapture && cargo build`

## Edge Cases
- Entity deleted between enqueue and drain: API returns 404, fail_job with "entity not found" (retry won't help but backoff caps it)
- Rate limiting during drain: GitLabRateLimited error should fail_job with retry (transient)
- Network error during drain: GitLabNetworkError should fail_job with retry
- Multiple sync runs competing: locked_at prevents double-processing; stale lock reclaim handles crashes
- Drain should have a max iterations guard to prevent infinite loop if jobs keep failing and being retried within the same run

Implemented: enqueue + drain resource events in orchestrator, wired counts through ingest→sync pipeline, added progress events, 4 new tests, all 209 tests pass


Implement username resolution function

## Background
`lore me` needs deterministic username resolution before any DB query executes. Source precedence is fixed (`--user` > `config.gitlab.username`), and missing username is a usage failure (exit 2), not an internal error.

This bead also defines the reusable runtime usage-error pathway for me-related commands.

## Approach
### 1. Add explicit usage error plumbing
In `src/core/error.rs`:
- Add `ErrorCode::UsageError`.
- Display string: `USAGE_ERROR`.
- Exit code mapping: `2`.
- Add `LoreError::UsageError(String)`.
- Map `LoreError::UsageError(_)` to `ErrorCode::UsageError` in `code()`.

### 2. Implement resolver
In `src/cli/commands/me/mod.rs`:
```rust
pub fn resolve_username(cli_user: Option<&str>, config: &Config) -> Result<String>
```
Rules:
- Trim leading/trailing whitespace before validation and return.
- CLI value wins if trimmed value is non-empty.
- Otherwise use trimmed `config.gitlab.username` if non-empty.
- Otherwise return `LoreError::UsageError` with actionable remediation text.

### 3. Preserve case
Do not lowercase or case-normalize username; only trim surrounding whitespace.

## Acceptance Criteria
- [ ] `ErrorCode::UsageError` exists and renders as `USAGE_ERROR`
- [ ] `ErrorCode::UsageError` maps to exit code `2`
- [ ] `LoreError::UsageError(String)` exists
- [ ] `LoreError::UsageError(_)` maps to `ErrorCode::UsageError`
- [ ] `resolve_username(Some("cli_user"), config)` returns `"cli_user"` regardless of config
- [ ] `resolve_username(None, config_with_username)` returns config username
- [ ] `resolve_username(None, config_without_username)` returns `LoreError::UsageError`
- [ ] `resolve_username(Some("  jdoe  "), config)` returns `"jdoe"`
- [ ] Empty/whitespace-only CLI value falls through to config
- [ ] Empty/whitespace-only config username is treated as missing
- [ ] Error text includes both config and CLI remediation paths

## Files
- MODIFY: `src/core/error.rs`
- MODIFY: `src/cli/commands/me/mod.rs`

## TDD Anchor
RED:
- `test_username_cli_overrides_config`
- `test_username_from_config_when_cli_missing`
- `test_username_missing_returns_usage_error`
- `test_username_whitespace_trimmed`
- `test_usage_error_code_is_exit_2`

GREEN:
- Add usage-error plumbing.
- Implement resolver with trimmed, case-preserving precedence.

VERIFY:
- `cargo test resolve_username`
- `cargo test usage_error_code`

## Edge Cases
- Input containing only whitespace (`"   "`) is treated as missing.
- Mixed-case usernames remain unchanged after resolution.

## Dependency Context
Consumes config field from `bd-qpk3` and provides error semantics used by:
- `bd-a7ba` (scope usage errors)
- `bd-1vv8` (invalid `--since` usage errors)
- `bd-32aw` (error-path compliance)

Dependencies:
  -> bd-qpk3 (blocks) - Add gitlab.username config field

Dependents:
  <- bd-utt4 (blocks) - Define MeArgs struct and register me subcommand


Extract query functions from CLI to shared pub API

## Background
The TUI's action.rs bridges to existing CLI query functions. To avoid code duplication, the existing query_* functions in cli/commands/*.rs need to be made pub so action.rs can call them. This is the minimal refactoring approach — no new domain query layer, just visibility changes.

## Approach
Modify existing CLI command files to extract and expose query functions:
- src/cli/commands/list.rs: make query_issues(), query_mrs() pub
- src/cli/commands/show.rs: make query_issue_detail(), query_mr_detail() pub
- src/cli/commands/who.rs: make query_experts(), query_workload(), query_reviews(), query_active(), query_overlap() pub
- src/cli/commands/search.rs: make run_search_query() pub

These functions should take Connection + parameters and return Result<T, LoreError>. Any CLI-specific formatting logic stays in the CLI; only the pure query logic is extracted.

If a function mixes query + format logic, split it:
1. query_X() -> Result<Vec<T>, LoreError> (pure query, made pub)
2. format_X(data: &[T]) -> String (CLI-only formatting, stays private)

## Acceptance Criteria
- [ ] query_issues() is pub and callable from outside cli module
- [ ] query_mrs() is pub and callable
- [ ] query_issue_detail() and query_mr_detail() are pub
- [ ] query_experts() and other who functions are pub
- [ ] run_search_query() is pub
- [ ] Existing CLI behavior unchanged (no functional changes)
- [ ] All extracted functions take Connection + params, return Result
- [ ] cargo test passes (no regressions)

## Files
- MODIFY: src/cli/commands/list.rs (make query functions pub)
- MODIFY: src/cli/commands/show.rs (make query functions pub)
- MODIFY: src/cli/commands/who.rs (make query functions pub)
- MODIFY: src/cli/commands/search.rs (make search query pub)

## TDD Anchor
RED: Write test in lore-tui action.rs that calls crate::cli::commands::list::query_issues() and asserts it compiles.
GREEN: Make query_issues pub.
VERIFY: cargo test --all-targets

## Edge Cases
- Some query functions may have Config dependencies — extract only the Connection-dependent parts
- Visibility changes may expose functions that weren't designed for external use — review signatures
- This is a non-breaking change (additive pub visibility)

## Dependency Context
This modifies the main lore crate (stable Rust).
Required by all TUI action.rs query bridge functions.
Must be completed before TUI can fetch real data.


Implement reviewing MRs query for me command

## Background
The third dashboard section shows MRs where the user is a reviewer. Reviewers tracked in `mr_reviewers(merge_request_id, username)`. Labels in `mr_labels` junction table. This section shows MRs by OTHER people that the user needs to review, so it includes the MR author's username.

## Approach
Add to `src/cli/commands/me/mrs.rs` (same file as authored MRs):
```rust
pub fn fetch_my_reviewing_mrs(
    conn: &Connection,
    username: &str,
    scope: &ProjectScope,
) -> Result<Vec<MeMrReviewing>> {
    let project_filter = match scope {
        ProjectScope::Single(id) => format!("AND p.id = {id}"),
        ProjectScope::All => String::new(),
    };
    let sql = format!(r#"
        SELECT p.path_with_namespace, mr.iid, mr.title, mr.state,
               mr.author_username, mr.draft, mr.updated_at,
               mr.web_url,
               (SELECT GROUP_CONCAT(l.name, ',')
                FROM mr_labels ml
                JOIN labels l ON l.id = ml.label_id
                WHERE ml.merge_request_id = mr.id) AS label_names
        FROM merge_requests mr
        JOIN mr_reviewers mrr ON mrr.merge_request_id = mr.id
        JOIN projects p ON p.id = mr.project_id
        WHERE mrr.username = ?1
          AND mr.state = 'opened'
          {project_filter}
        ORDER BY mr.updated_at DESC
    "#);
    // ... map rows to MeMrReviewing (same pattern as authored)
}
```

Key difference from authored: `author_username` included (who wrote the MR), joins through `mr_reviewers` not `author_username`.

## Acceptance Criteria
- [ ] Returns Vec<MeMrReviewing> for MRs where `mr_reviewers.username` matches (AC-3.3)
- [ ] Only returns `state = 'opened'` MRs (AC-5.3)
- [ ] Includes `author_username` (who wrote the MR) (AC-7.5)
- [ ] Includes `draft` as bool
- [ ] Labels via GROUP_CONCAT on `mr_labels` + `labels`
- [ ] Respects ProjectScope filtering
- [ ] User both author AND reviewer of same MR → appears in BOTH sections (valid)
- [ ] No limit, no truncation

## Files
- MODIFY: src/cli/commands/me/mrs.rs (add alongside authored query)

## TDD Anchor
RED: Write `test_fetch_reviewing_mrs` using in-memory DB. Insert project, MR by "alice", mr_reviewers row for "jdoe". Assert returns 1 MR with author_username="alice".
GREEN: Implement the SQL query.
VERIFY: `cargo test fetch_my_reviewing_mrs`

Additional tests:
- test_reviewing_mrs_excludes_non_reviewer (no mr_reviewers row → not returned)
- test_user_both_author_and_reviewer (insert MR where user is author + add reviewer row → appears)

## Edge Cases
- `mr_reviewers` has no timestamp — can't tell when review was requested from this table
- Multiple reviewers → our user is one of N → MR appears once (JOIN, PK is unique)

## Dependency Context
Uses `MeMrReviewing` and `ProjectScope` from bd-3bwh and bd-a7ba.
Same file as bd-1obt (authored MRs). Uses `ms_to_iso` from `src/core/time.rs`.


[CP1] Integration tests for discussion watermark

Integration tests verifying discussion sync watermark behavior.

## Tests (tests/discussion_watermark_tests.rs)

- skips_discussion_fetch_when_updated_at_unchanged
- fetches_discussions_when_updated_at_advanced
- updates_watermark_after_successful_discussion_sync
- does_not_update_watermark_on_discussion_sync_failure

## Test Scenario
1. Ingest issue with updated_at = T1
2. Verify discussions_synced_for_updated_at = T1
3. Re-run ingest with same issue (updated_at = T1)
4. Verify NO discussion API calls made (watermark prevents)
5. Simulate issue update (updated_at = T2)
6. Re-run ingest
7. Verify discussion API calls made for T2
8. Verify watermark updated to T2

## Why This Matters
Discussion API is expensive (1 call per issue). Watermark ensures
we only refetch when issue actually changed, even with cursor rewind.

Files: tests/discussion_watermark_tests.rs
Done when: Watermark correctly prevents redundant discussion refetch


[CP0] gi auth-test command

## Background

auth-test is a quick diagnostic command to verify GitLab connectivity. Used for troubleshooting and CI pipelines. Simpler than doctor because it only checks auth, not full system health.

Reference: docs/prd/checkpoint-0.md section "gi auth-test"

## Approach

**src/cli/commands/auth-test.ts:**
```typescript
import { Command } from 'commander';
import { loadConfig } from '../../core/config';
import { GitLabClient } from '../../gitlab/client';
import { TokenNotSetError } from '../../core/errors';

export const authTestCommand = new Command('auth-test')
  .description('Verify GitLab authentication')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();

    // 1. Load config
    const config = loadConfig(globalOpts.config);

    // 2. Get token from environment
    const token = process.env[config.gitlab.tokenEnvVar];
    if (!token) {
      throw new TokenNotSetError(config.gitlab.tokenEnvVar);
    }

    // 3. Create client and test auth
    const client = new GitLabClient({
      baseUrl: config.gitlab.baseUrl,
      token,
    });

    // 4. Get current user
    const user = await client.getCurrentUser();

    // 5. Output success
    console.log(`Authenticated as @${user.username} (${user.name})`);
    console.log(`GitLab: ${config.gitlab.baseUrl}`);
  });
```

**Output format:**
```
Authenticated as @johndoe (John Doe)
GitLab: https://gitlab.example.com
```

## Acceptance Criteria

- [ ] Loads config from default or --config path
- [ ] Gets token from configured env var (default GITLAB_TOKEN)
- [ ] Throws TokenNotSetError if env var not set
- [ ] Calls GET /api/v4/user to verify auth
- [ ] Prints username and display name on success
- [ ] Exit 0 on success
- [ ] Exit 1 on auth failure (GitLabAuthError)
- [ ] Exit 1 if config not found (ConfigNotFoundError)

## Files

CREATE:
- src/cli/commands/auth-test.ts

## TDD Loop

N/A - simple command, verify manually and with integration test in init.test.ts

```bash
# Manual verification
export GITLAB_TOKEN="valid-token"
gi auth-test

# With invalid token
export GITLAB_TOKEN="invalid"
gi auth-test  # should exit 1
```

## Edge Cases

- Config exists but token env var not set - clear error message
- Token exists but wrong scopes - GitLabAuthError (401)
- Network unreachable - GitLabNetworkError
- Token with extra whitespace - should trim


Implement status fetcher with adaptive paging and pagination guard

## Background
With the GraphQL client in place, we need a status-specific fetcher that paginates through all issues in a project, extracts status widgets via __typename matching, and handles edge cases like complexity errors and cursor stalls.

## Approach
All code goes in src/gitlab/graphql.rs alongside GraphqlClient. The fetcher uses the workItems(types:[ISSUE]) resolver (NOT project.issues which returns the old Issue type without status widgets). Widget matching uses __typename == "WorkItemWidgetStatus" for deterministic identification.

## Files
- src/gitlab/graphql.rs (add to existing file created by bd-2dlt)

## Implementation

Constants:
  ISSUE_STATUS_QUERY: GraphQL query string with $projectPath, $after, $first variables
  PAGE_SIZES: &[u32] = &[100, 50, 25, 10]

Private deserialization types:
  WorkItemsResponse { project: Option<ProjectNode> }
  ProjectNode { work_items: Option<WorkItemConnection> } (serde rename workItems)
  WorkItemConnection { nodes: Vec<WorkItemNode>, page_info: PageInfo } (serde rename pageInfo)
  WorkItemNode { iid: String, widgets: Vec<serde_json::Value> }
  PageInfo { end_cursor: Option<String>, has_next_page: bool } (serde renames)
  StatusWidget { status: Option<WorkItemStatus> }

Public types:
  UnsupportedReason enum: GraphqlEndpointMissing, AuthForbidden (Debug, Clone)
  FetchStatusResult struct:
    statuses: HashMap<i64, WorkItemStatus>
    all_fetched_iids: HashSet<i64>
    unsupported_reason: Option<UnsupportedReason>
    partial_error_count: usize
    first_partial_error: Option<String>

is_complexity_or_timeout_error(msg) -> bool: lowercase contains "complexity" or "timeout"

fetch_issue_statuses(client, project_path) -> Result<FetchStatusResult>:
  Pagination loop:
    1. Build variables with current page_size from PAGE_SIZES[page_size_idx]
    2. Call client.query() — match errors:
       - GitLabNotFound -> Ok(empty + GraphqlEndpointMissing) + warn
       - GitLabAuthFailed -> Ok(empty + AuthForbidden) + warn
       - Other with complexity/timeout msg -> reduce page_size_idx, continue (retry same cursor)
       - Other with smallest page size exhausted -> return Err
       - Other -> return Err
    3. Track partial errors from GraphqlQueryResult
    4. Parse response into WorkItemsResponse
    5. For each node: parse iid to i64, add to all_fetched_iids, check widgets for __typename == "WorkItemWidgetStatus", insert status into map
    6. Reset page_size_idx to 0 after successful page
    7. Pagination guard: if has_next_page but new cursor == old cursor or is None, warn + break
    8. Update cursor, continue loop

## Acceptance Criteria
- [ ] Paginates: 2-page mock returns all statuses + all IIDs
- [ ] No status widget: IID in all_fetched_iids but not in statuses
- [ ] Status widget with null status: IID in all_fetched_iids but not in statuses
- [ ] 404 -> Ok(empty, unsupported_reason: GraphqlEndpointMissing)
- [ ] 403 -> Ok(empty, unsupported_reason: AuthForbidden)
- [ ] Success -> unsupported_reason: None
- [ ] __typename != "WorkItemWidgetStatus" -> ignored, no error
- [ ] Cursor stall (same endCursor twice) -> aborts, returns partial result
- [ ] Complexity error at first=100 -> retries at 50, succeeds
- [ ] Timeout error -> reduces page size
- [ ] All page sizes fail -> returns Err
- [ ] After successful page, next page starts at first=100 again
- [ ] Partial-data pages -> partial_error_count incremented, first_partial_error captured

## TDD Loop
RED: test_fetch_statuses_pagination, test_fetch_statuses_no_status_widget, test_fetch_statuses_404_graceful, test_fetch_statuses_403_graceful, test_typename_matching_ignores_non_status_widgets, test_fetch_statuses_cursor_stall_aborts, test_fetch_statuses_complexity_error_reduces_page_size, test_fetch_statuses_timeout_error_reduces_page_size, test_fetch_statuses_smallest_page_still_fails, test_fetch_statuses_page_size_resets_after_success, test_fetch_statuses_unsupported_reason_none_on_success, test_fetch_statuses_partial_errors_tracked
  Adaptive tests: mock must inspect $first variable in request body to return different responses per page size
GREEN: Implement all types + fetch_issue_statuses function
VERIFY: cargo test fetch_statuses && cargo test typename

## Edge Cases
- GraphQL returns iid as String — parse to i64
- widgets is Vec<serde_json::Value> — match __typename field, then deserialize matching widgets
- let-chain syntax: if is_status_widget && let Ok(sw) = serde_json::from_value::<StatusWidget>(...)
- Pagination guard: new_cursor.is_none() || new_cursor == cursor
- Page size resets to 0 (index into PAGE_SIZES) after each successful page
- FetchStatusResult is NOT Clone — test fields individually

Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)


Add rename awareness to path resolution probes

## Background
The path resolution layer (build_path_query at who.rs:467 and suffix_probe at who.rs:596) only checks position_new_path and new_path. If a user queries an old filename (e.g., 'login.rs' after rename to 'auth.rs'), the probes return 'not found' and scoring never runs — even though the scoring SQL (bd-1hoq) now matches old_path.

## Approach

### build_path_query() changes (who.rs:467):

Probe 1 (exact_exists):
- Notes query: add OR position_old_path = ?1
- File changes query: add OR old_path = ?1

Probe 2 (prefix_exists):
- Notes query: add OR position_old_path LIKE ?1 ESCAPE '\\'
- File changes query: add OR old_path LIKE ?1 ESCAPE '\\'

Note: These probes use simple OR (not UNION ALL) since they only check existence (SELECT 1 ... LIMIT 1) — no risk of planner degradation on single-row probes.

### suffix_probe() changes (who.rs:596):

Add two UNION branches to the existing query:
```sql
UNION
SELECT position_old_path AS full_path FROM notes
WHERE note_type = 'DiffNote' AND is_system = 0
  AND position_old_path IS NOT NULL
  AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
UNION
SELECT old_path AS full_path FROM mr_file_changes
WHERE old_path IS NOT NULL
  AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
```

Use UNION (not UNION ALL) — the existing query uses UNION for dedup.

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_old_path_probe_exact_and_prefix() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/new/foo.rs", Some("src/old/foo.rs"), "renamed");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(&conn, 1, 1, 1, "alice", "src/new/foo.rs", Some("src/old/foo.rs"), "review comment", now_ms());

    // Exact probe by OLD path should resolve
    let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. } | PathQuery::Prefix { .. }));

    // Prefix probe by OLD directory should resolve
    let pq = build_path_query(&conn, "src/old/", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Prefix { .. }));

    // New path still works
    let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. }));
}

#[test]
fn test_suffix_probe_uses_old_path_sources() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/utils.rs", Some("legacy/utils.rs"), "renamed");

    let result = suffix_probe(&conn, "utils.rs", None).unwrap();
    match result {
        SuffixResult::Ambiguous(paths) => {
            assert\!(paths.contains(&"src/utils.rs".to_string()));
            assert\!(paths.contains(&"legacy/utils.rs".to_string()));
        }
        SuffixResult::Unique(p) => {
            assert\!(p == "src/utils.rs" || p == "legacy/utils.rs");
        }
        other => panic\!("Expected Ambiguous or Unique, got {other:?}"),
    }
}
```

### GREEN: Add OR old_path clauses to probes + UNION branches to suffix_probe.
### VERIFY: cargo test -p lore -- test_old_path_probe test_suffix_probe_uses_old_path

## Acceptance Criteria
- [ ] test_old_path_probe_exact_and_prefix passes
- [ ] test_suffix_probe_uses_old_path_sources passes
- [ ] Existing path probe tests still pass
- [ ] No changes to PathQuery or SuffixResult enums

## Files
- MODIFY: src/cli/commands/who.rs (build_path_query at line 467, suffix_probe at line 596)

## Edge Cases
- position_old_path can be NULL — OR clause handles naturally (NULL \!= ?1)
- Old path might match multiple new paths (copy+rename) — suffix_probe Ambiguous handles this
- Requires insert_file_change_with_old_path and insert_diffnote_at helpers from bd-2yu5

Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green


[CP1] Ingestion orchestrator

Coordinate issue + dependent discussion sync with bounded concurrency.

## Module
src/ingestion/orchestrator.rs

## Canonical Pattern (CP1)

When gi ingest --type=issues runs:

1. **Ingest issues** - cursor-based with incremental cursor updates per page
2. **Collect touched issues** - record IssueForDiscussionSync for each issue passing cursor filter
3. **Filter for discussion sync** - enqueue issues where:
   issue.updated_at > issues.discussions_synced_for_updated_at
4. **Execute discussion sync** - with bounded concurrency (dependent_concurrency from config)
5. **Update watermark** - after each issue's discussions successfully ingested

## Concurrency Notes

Runtime decision: Use single-threaded Tokio runtime (flavor = "current_thread")
- rusqlite::Connection is !Send, conflicts with multi-threaded runtimes
- Single-threaded avoids Send bounds entirely
- Use tokio::task::spawn_local + LocalSet for concurrent discussion fetches
- Keeps code simple; can upgrade to channel-based DB writer in CP2 if needed

## Configuration Used
- config.sync.dependent_concurrency - limits parallel discussion requests
- config.sync.cursor_rewind_seconds - safety margin for cursor

## Progress Reporting
- Show total issues fetched
- Show issues needing discussion sync
- Show discussion/note counts per project

Files: src/ingestion/orchestrator.rs
Tests: Integration tests with mocked GitLab
Done when: Full issue + discussion ingestion orchestrated correctly


Restructure expert SQL with CTE-based dual-path matching

## Background
The current query_expert() at who.rs:641 uses a 4-signal UNION ALL that only matches position_new_path and new_path, with flat COUNT-based scoring computed entirely in SQL. The new model needs dual-path matching, 5 signal types, state-aware timestamps, and returns per-signal rows for Rust-side decay computation (bd-13q8).

## Approach
**Important**: This bead builds the new SQL as a separate function WITHOUT modifying query_expert() yet. bd-13q8 wires it into query_expert(). This keeps this bead independently testable.

Add a new function:
```rust
/// Build the CTE-based expert scoring SQL for a given path query mode.
/// Returns SQL string. Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
fn build_expert_sql(path_op: &str) -> String {
    // ... format the SQL with {path_op} inlined, all config values as bound params
}
```

### SQL structure (8 CTEs + final SELECT):
1. **matched_notes_raw**: UNION ALL on position_new_path + position_old_path
2. **matched_notes**: DISTINCT dedup by id
3. **matched_file_changes_raw**: UNION ALL on new_path + old_path
4. **matched_file_changes**: DISTINCT dedup by (merge_request_id, project_id)
5. **mr_activity**: Centralized state-aware timestamps AND state_mult. Joins merge_requests via matched_file_changes. Computes:
   - activity_ts: CASE WHEN state='merged' THEN COALESCE(merged_at, created_at) WHEN state='closed' THEN COALESCE(closed_at, created_at) ELSE COALESCE(updated_at, created_at) END
   - state_mult: CASE WHEN state='closed' THEN ?5 ELSE 1.0 END
6. **reviewer_participation**: substantive DiffNotes WHERE LENGTH(TRIM(body)) >= ?6
7. **raw**: 5 signals (diffnote_reviewer, diffnote_author, file_author, file_reviewer_participated, file_reviewer_assigned). Signals 1-2 compute state_mult inline. Signals 3-4a-4b reference mr_activity.
8. **aggregated**: MR-level GROUP BY + note_group with COUNT

### Returns 6 columns: (username TEXT, signal TEXT, mr_id INTEGER, qty INTEGER, ts INTEGER, state_mult REAL)

See plans/time-decay-expert-scoring.md section 3 for the full SQL template.

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_expert_sql_returns_expected_signal_rows() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_reviewer(&conn, 1, "bob");
    insert_reviewer(&conn, 1, "carol");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "carol", "src/app.rs", "This needs error handling for the edge case");

    let sql = build_expert_sql("= ?1");
    let mut stmt = conn.prepare(&sql).unwrap();
    let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt
        .query_map(
            rusqlite::params!["src/app.rs", 0_i64, Option::<i64>::None, now_ms() + 1000, 0.5_f64, 20_i64],
            |row| Ok((
                row.get(0).unwrap(), row.get(1).unwrap(), row.get(2).unwrap(),
                row.get(3).unwrap(), row.get(4).unwrap(), row.get(5).unwrap(),
            ))
        ).unwrap().filter_map(|r| r.ok()).collect();

    // alice: file_author
    assert!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "file_author"));
    // carol: file_reviewer_participated (left substantive DiffNote)
    assert!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "file_reviewer_participated"));
    // bob: file_reviewer_assigned (no DiffNotes)
    assert!(rows.iter().any(|(u, s, ..)| u == "bob" && s == "file_reviewer_assigned"));
    // carol: note_group
    assert!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "note_group"));
    // alice: diffnote_author
    assert!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "diffnote_author"));
    // All merged rows have state_mult = 1.0
    assert!(rows.iter().all(|(.., sm)| (sm - 1.0).abs() < f64::EPSILON));
}
```

### GREEN: Implement build_expert_sql() with the 8 CTEs.
### VERIFY: cargo test -p lore -- test_expert_sql_returns_expected_signal_rows

## Acceptance Criteria
- [ ] test_expert_sql_returns_expected_signal_rows passes (all 5 signal types correct)
- [ ] SQL compiles against :memory: DB with indexes from bd-2ao4 (migration 026)
- [ ] 6 columns returned: username, signal, mr_id, qty, ts, state_mult (REAL, not TEXT)
- [ ] 6 SQL params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
- [ ] mr_activity CTE centralizes timestamp + state_mult (not repeated)
- [ ] reviewer_participation uses ?6 not inlined literal
- [ ] Existing query_expert() and all existing tests UNTOUCHED
- [ ] build_expert_sql() is a pure function (no Connection param)

## Files
- MODIFY: src/cli/commands/who.rs (new build_expert_sql function + test, placed near query_expert at line ~641)

## Edge Cases
- ?5 (closed_mr_multiplier) bound as f64 — rusqlite handles this
- ?6 (reviewer_min_note_chars) bound as i64 — SQLite LENGTH returns integer
- Signals 1-2 compute state_mult inline (join through discussions, not mr_activity)
- COALESCE fallback to created_at for NULL merged_at/closed_at/updated_at
- Dedup in matched_notes/matched_file_changes prevents double-counting


Epic: Gate 5 - Code Trace (lore trace)

## Background

Gate 5 implements 'lore trace' — answers 'Why was this code introduced?' by tracing from a file path through the MR that modified it, to the issue that motivated the MR, to the discussions with decision rationale. Capstone of Phase B.

Gate 5 ships Tier 1 only (API-only, no local git). Tier 2 (git blame via git2-rs) deferred to Phase C.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 5 (Sections 5.1-5.7).

## Prerequisites

- Gates 1-2 COMPLETE: entity_references populated, resource events fetched
- Gate 4 (bd-14q): provides mr_file_changes table + resolve_rename_chain algorithm
- entity_references source_method: 'api' | 'note_parse' | 'description_parse'
- discussions/notes tables for DiffNote content
- merge_requests.merged_at exists (migration 006). Use COALESCE(merged_at, updated_at) for ordering.

## Architecture

- **No new tables.** Trace queries combine mr_file_changes, entity_references, discussions/notes
- **Query flow:** file -> mr_file_changes -> MRs -> entity_references (closes/related) -> issues -> discussions with DiffNote context
- **Tier 1:** File-level granularity only. Cannot trace a specific line to its introducing commit.
- **Path parsing:** Supports 'src/foo.rs:45' syntax — line number parsed but deferred with Tier 2 warning.
- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.

## Children (Execution Order)

1. **bd-2n4** — Trace query logic: file -> MR -> issue -> discussion chain (src/core/trace.rs)
2. **bd-9dd** — CLI command with human + robot output (src/cli/commands/trace.rs)

## Gate Completion Criteria

- [ ] `lore trace <file>` shows MRs with linked issues + discussion context
- [ ] Output includes MR -> issue -> discussion chain
- [ ] DiffNote snippets show content on the traced file
- [ ] Cross-references from entity_references used for MR->issue linking
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] Robot mode JSON with tier: 'api_only'
- [ ] Graceful handling when no MR data found (suggest sync with fetchMrFileChanges)

Gate 5 complete. trace command shipped, all impl children closed. Only doc update tasks (bd-1v8, bd-2fc) remain as independent tasks.


Integrate mark_dirty_tx into ingestion modules

## Background
This bead integrates dirty source tracking into the existing ingestion pipelines. Every entity upserted during ingestion must be marked dirty so the document regenerator knows to update the corresponding search document. The critical constraint: mark_dirty_tx() must be called INSIDE the same transaction that upserts the entity — not after commit.

**Key PRD clarification:** Mark ALL upserted entities dirty (not just changed ones). The regenerator's hash comparison handles "unchanged" detection cheaply — this avoids needing change detection in ingestion.

## Approach
Modify 4 existing ingestion files to add mark_dirty_tx() calls inside existing transaction blocks per PRD Section 6.1.

**1. src/ingestion/issues.rs:**
Inside the issue upsert loop, after each successful INSERT/UPDATE:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Issue, issue_row.id)?;
```

**2. src/ingestion/merge_requests.rs:**
Inside the MR upsert loop:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::MergeRequest, mr_row.id)?;
```

**3. src/ingestion/discussions.rs:**
Inside discussion insert (issue discussions, full-refresh transaction):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**4. src/ingestion/mr_discussions.rs:**
Inside discussion upsert (write phase):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**Discussion Sweep Cleanup (PRD Section 6.1 — CRITICAL):**
When the MR discussion sweep deletes stale discussions (`last_seen_at < run_start_time`), **delete the corresponding document rows directly** — do NOT use the dirty queue for cleanup. The `ON DELETE CASCADE` on `document_labels`/`document_paths` and the `documents_embeddings_ad` trigger handle all downstream cleanup.

**PRD-exact CTE pattern:**
```sql
-- In src/ingestion/mr_discussions.rs, during sweep phase.
-- Uses a CTE to capture stale IDs atomically before cascading deletes.
-- This is more defensive than two separate statements because the CTE
-- guarantees the ID set is captured before any row is deleted.
WITH stale AS (
  SELECT id FROM discussions
  WHERE merge_request_id = ? AND last_seen_at < ?
)
-- Step 1: delete orphaned documents (must happen while source_id still resolves)
DELETE FROM documents
  WHERE source_type = 'discussion' AND source_id IN (SELECT id FROM stale);
-- Step 2: delete the stale discussions themselves
DELETE FROM discussions
  WHERE id IN (SELECT id FROM stale);
```

**NOTE:** If SQLite version doesn't support CTE-based multi-statement, execute as two sequential statements capturing IDs in Rust first:
```rust
let stale_ids: Vec<i64> = conn.prepare(
    "SELECT id FROM discussions WHERE merge_request_id = ? AND last_seen_at < ?"
)?.query_map(params![mr_id, run_start], |r| r.get(0))?
  .collect::<Result<Vec<_>, _>>()?;

if !stale_ids.is_empty() {
    // Delete documents FIRST (while source_id still resolves)
    conn.execute(
        "DELETE FROM documents WHERE source_type = 'discussion' AND source_id IN (...)",
        ...
    )?;
    // Then delete the discussions
    conn.execute(
        "DELETE FROM discussions WHERE id IN (...)",
        ...
    )?;
}
```

**IMPORTANT difference from dirty queue pattern:** The sweep deletes documents DIRECTLY (not via dirty_sources queue). This is because the source entity is being deleted — there's nothing for the regenerator to regenerate from. The cascade handles FTS, labels, paths, and embeddings cleanup.

## Acceptance Criteria
- [ ] Every upserted issue is marked dirty inside the same transaction
- [ ] Every upserted MR is marked dirty inside the same transaction
- [ ] Every upserted discussion (issue + MR) is marked dirty inside the same transaction
- [ ] ALL upserted entities marked dirty (not just changed ones) — regenerator handles skip
- [ ] mark_dirty_tx called with &Transaction (not &Connection)
- [ ] mark_dirty_tx uses upsert with ON CONFLICT to reset backoff state (not INSERT OR IGNORE)
- [ ] Discussion sweep deletes documents DIRECTLY (not via dirty queue)
- [ ] Discussion sweep uses CTE (or Rust-side ID capture) to capture stale IDs before cascading deletes
- [ ] Documents deleted BEFORE discussions (while source_id still resolves)
- [ ] ON DELETE CASCADE handles document_labels, document_paths cleanup
- [ ] documents_embeddings_ad trigger handles embedding cleanup
- [ ] `cargo build` succeeds
- [ ] Existing ingestion tests still pass

## Files
- `src/ingestion/issues.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/merge_requests.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/discussions.rs` — add mark_dirty_tx calls in insert loop
- `src/ingestion/mr_discussions.rs` — add mark_dirty_tx calls + direct document deletion in sweep

## TDD Loop
RED: Existing tests should still pass (regression); new tests:
- `test_issue_upsert_marks_dirty` — after issue ingest, dirty_sources has entry
- `test_mr_upsert_marks_dirty` — after MR ingest, dirty_sources has entry
- `test_discussion_upsert_marks_dirty` — after discussion ingest, dirty_sources has entry
- `test_discussion_sweep_deletes_documents` — stale discussion documents deleted directly
- `test_sweep_cascade_cleans_labels_paths` — ON DELETE CASCADE works
GREEN: Add mark_dirty_tx calls in all 4 files, implement sweep with CTE
VERIFY: `cargo test ingestion && cargo build`

## Edge Cases
- Upsert that doesn't change data: still marks dirty (regenerator hash check handles skip)
- Transaction rollback: dirty mark also rolled back (atomic, inside same txn)
- Discussion sweep with zero stale IDs: CTE returns empty, no DELETE executed
- Large batch of upserts: each mark_dirty_tx is O(1) INSERT with ON CONFLICT
- Sweep deletes document before discussion: order matters for source_id resolution

Added mark_dirty_tx calls in issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (2 paths)


Implement run_sync_surgical orchestration function

## Background

The surgical sync pipeline needs a top-level orchestration function that coordinates the full pipeline for syncing specific IIDs. Unlike `run_sync` (lines 63-360 of `src/cli/commands/sync.rs`) which syncs all projects and all entities, `run_sync_surgical` targets specific issues/MRs by IID within a single project. The pipeline stages are: resolve project, record sync run, preflight fetch, check cancellation, acquire lock, ingest with TOCTOU guards, inline dependent enrichment (discussions, events, diffs), scoped doc regeneration, scoped embedding, finalize recorder, and build `SyncResult`.

## Approach

Create `pub async fn run_sync_surgical()` in a new file `src/cli/commands/sync_surgical.rs`. Signature:

```rust
pub async fn run_sync_surgical(
    config: &Config,
    options: SyncOptions,
    run_id: Option<&str>,
    signal: &ShutdownSignal,
) -> Result<SyncResult>
```

The function reads `options.issue_iids` and `options.mr_iids` (added by bd-1lja) to determine target IIDs. Pipeline:

1. **Resolve project**: Call `resolve_project(conn, project_str)` from `src/core/project.rs` to get `gitlab_project_id`.
2. **Start recorder**: `SyncRunRecorder::start(&recorder_conn, "surgical-sync", run_id)`. Note: `succeed()` and `fail()` consume `self`, so control flow must ensure exactly one terminal call.
3. **Preflight fetch**: For each IID, call `get_issue_by_iid` / `get_mr_by_iid` (bd-159p) to confirm the entity exists on GitLab and capture `updated_at` for TOCTOU.
4. **Check cancellation**: `if signal.is_cancelled() { recorder.fail(...); return Ok(result); }`
5. **Acquire lock**: `AppLock::new(conn, LockOptions { name: "surgical-sync".into(), stale_lock_minutes: config.sync.stale_lock_minutes, heartbeat_interval_seconds: config.sync.heartbeat_interval_seconds })`. Lock must `acquire(force)` and `release()` on all exit paths.
6. **Ingest with TOCTOU**: For each preflight entity, call surgical ingest (bd-3sez). Compare DB `updated_at` with preflight `updated_at`; skip if already current. Record outcome in `EntitySyncResult`.
7. **Inline dependents**: For ingested entities, fetch discussions, resource events (if `config.sync.fetch_resource_events`), MR diffs (if `config.sync.fetch_mr_file_changes`). Use `config.sync.requests_per_second` for rate limiting.
8. **Scoped docs**: Call `run_generate_docs_for_sources()` (bd-hs6j) with only the affected entity source IDs.
9. **Scoped embed**: Call `run_embed_for_document_ids()` (bd-1elx) with only the regenerated document IDs.
10. **Finalize**: `recorder.succeed(conn, &metrics, total_items, total_errors)`.
11. **Build SyncResult**: Populate surgical fields (bd-wcja): `surgical_mode: Some(true)`, `surgical_iids`, `entity_results`, `preflight_only`.

If `options.preflight_only` is set, return after step 3 with the preflight data and skip steps 4-10.

Progress output uses `stage_spinner_v2(icon, label, msg, robot_mode)` from `src/cli/progress.rs` line 18 during execution, and `format_stage_line(icon, label, summary, elapsed)` from `src/cli/progress.rs` line 67 for completion lines. Stage icons via `Icons::sync()` from `src/cli/render.rs` line 208. Error completion uses `color_icon(icon, has_errors)` from `src/cli/commands/sync.rs` line 55.

## Acceptance Criteria

1. `run_sync_surgical` compiles and runs the full pipeline for 1+ issue IIDs
2. Preflight-only mode returns early with fetched entity data, no DB writes beyond recorder
3. TOCTOU: entities whose DB `updated_at` matches preflight `updated_at` are skipped with `skipped_toctou` outcome
4. Cancellation at any stage between preflight and ingest stops processing, calls `recorder.fail()`
5. Lock is acquired before ingest and released on all exit paths (success, error, cancellation)
6. `SyncResult` surgical fields are populated: `surgical_mode`, `surgical_iids`, `entity_results`
7. Robot mode produces valid JSON with per-entity outcomes
8. Human mode shows stage spinners and completion lines

## Files

- `src/cli/commands/sync_surgical.rs` — new file, main orchestration function
- `src/cli/commands/mod.rs` — add `pub mod sync_surgical;`

## TDD Anchor

Tests in `src/cli/commands/sync_surgical.rs` or a companion `sync_surgical_tests.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::db::{create_connection, run_migrations};
    use std::path::Path;
    use wiremock::{MockServer, Mock, ResponseTemplate};
    use wiremock::matchers::{method, path_regex};

    fn test_config(mock_url: &str) -> Config {
        let mut config = Config::default();
        config.gitlab.url = mock_url.to_string();
        config.gitlab.token = "test-token".to_string();
        config
    }

    fn setup_db() -> rusqlite::Connection {
        let conn = create_connection(Path::new(":memory:")).unwrap();
        run_migrations(&conn).unwrap();
        // Insert test project
        conn.execute(
            "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
             VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')",
            [],
        ).unwrap();
        conn
    }

    #[tokio::test]
    async fn surgical_sync_single_issue_end_to_end() {
        let server = MockServer::start().await;
        // Mock: GET /projects/:id/issues?iids[]=7 returns one issue
        Mock::given(method("GET"))
            .and(path_regex(r"/api/v4/projects/1/issues"))
            .respond_with(ResponseTemplate::new(200).set_body_json(
                serde_json::json!([{
                    "id": 100, "iid": 7, "project_id": 1, "title": "Test",
                    "state": "opened", "created_at": "2026-01-01T00:00:00Z",
                    "updated_at": "2026-02-17T00:00:00Z",
                    "author": {"id": 1, "username": "dev", "name": "Dev"},
                    "web_url": "https://gitlab.example.com/group/project/-/issues/7"
                }])
            ))
            .mount(&server).await;
        // Mock discussions endpoint
        Mock::given(method("GET"))
            .and(path_regex(r"/api/v4/projects/1/issues/7/discussions"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
            .mount(&server).await;

        let config = test_config(&server.uri());
        let options = SyncOptions {
            robot_mode: true,
            issue_iids: vec![7],
            ..SyncOptions::default()
        };
        let signal = ShutdownSignal::new();
        let result = run_sync_surgical(&config, options, Some("test01"), &signal).await.unwrap();

        assert_eq!(result.surgical_mode, Some(true));
        assert_eq!(result.surgical_iids.as_ref().unwrap().issues, vec![7]);
        let entities = result.entity_results.as_ref().unwrap();
        assert_eq!(entities.len(), 1);
        assert_eq!(entities[0].outcome, "synced");
    }

    #[tokio::test]
    async fn preflight_only_returns_early() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path_regex(r"/api/v4/projects/1/issues"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{
                "id": 100, "iid": 7, "project_id": 1, "title": "Test",
                "state": "opened", "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-02-17T00:00:00Z",
                "author": {"id": 1, "username": "dev", "name": "Dev"},
                "web_url": "https://gitlab.example.com/group/project/-/issues/7"
            }])))
            .mount(&server).await;

        let config = test_config(&server.uri());
        let options = SyncOptions {
            robot_mode: true,
            issue_iids: vec![7],
            preflight_only: true,
            ..SyncOptions::default()
        };
        let signal = ShutdownSignal::new();
        let result = run_sync_surgical(&config, options, Some("test02"), &signal).await.unwrap();

        assert_eq!(result.preflight_only, Some(true));
        assert_eq!(result.issues_updated, 0); // No actual ingest happened
    }

    #[tokio::test]
    async fn cancellation_before_ingest_fails_recorder() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path_regex(r"/api/v4/projects/1/issues"))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{
                "id": 100, "iid": 7, "project_id": 1, "title": "Test",
                "state": "opened", "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-02-17T00:00:00Z",
                "author": {"id": 1, "username": "dev", "name": "Dev"},
                "web_url": "https://gitlab.example.com/group/project/-/issues/7"
            }])))
            .mount(&server).await;

        let config = test_config(&server.uri());
        let options = SyncOptions {
            robot_mode: true,
            issue_iids: vec![7],
            ..SyncOptions::default()
        };
        let signal = ShutdownSignal::new();
        signal.cancel(); // Cancel before we start
        let result = run_sync_surgical(&config, options, Some("test03"), &signal).await.unwrap();

        // Result should indicate cancellation
        assert_eq!(result.issues_updated, 0);
    }
}
```

## Edge Cases

- **Entity not found on GitLab**: Preflight returns 404 for an IID. Record `EntitySyncResult { outcome: "not_found" }` and continue with remaining IIDs.
- **All entities skipped by TOCTOU**: Every entity's `updated_at` matches DB. Result has `entity_results` with all `skipped_toctou`, zero actual sync work.
- **Mixed success/failure**: Some IIDs succeed, some fail. All recorded in `entity_results`. Function returns `Ok` with partial results, not `Err`.
- **SyncRunRecorder consume semantics**: `succeed()` and `fail()` take `self` by value. The orchestrator must ensure exactly one terminal call. Use an `Option<SyncRunRecorder>` pattern: `let mut recorder = Some(recorder); ... recorder.take().unwrap().succeed(...)`.
- **Lock contention**: If another sync holds the lock and `force` is false, fail with clear error before any ingest.
- **Empty IID lists**: If both `options.issue_iids` and `options.mr_iids` are empty, return immediately with default `SyncResult` (no surgical fields set).

## Dependency Context

- **Depends on (upstream)**: bd-wcja (SyncResult fields), bd-1lja (SyncOptions extensions), bd-159p (get_by_iid client methods), bd-3sez (surgical ingest/preflight/TOCTOU), bd-kanh (per-entity helpers), bd-arka (SyncRunRecorder surgical methods), bd-1elx (scoped embed), bd-hs6j (scoped docs), bd-tiux (migration 027)
- **Blocks (downstream)**: bd-3bec (wiring into run_sync), bd-3jqx (integration tests)
- This is the keystone bead — it consumes all upstream primitives and is consumed by the final wiring and integration test beads.

run_sync_surgical orchestrator: 719-line pipeline with preflight/TOCTOU/ingest/dependents/docs/embed stages, Option<SyncRunRecorder> pattern, graceful embed failures


Phase 0d: Error type migration — NetworkErrorKind enum

## What
Modify LoreError in src/core/error.rs to:
1. Remove the reqwest::Error From impl (Http variant)
2. Add a NetworkErrorKind enum for error classification
3. Change GitLabNetworkError to use kind: NetworkErrorKind + detail: Option<String> instead of source: Option<reqwest::Error>

## Why
The adapter layer (Phase 1) uses GitLabNetworkError { detail: Option<String> }, which requires this error type change before the adapter compiles. This MUST precede Phase 1. Placed in Phase 0 so Phases 1-3 compile as a unit.

## Implementation

### New enum in src/core/error.rs:
\`\`\`rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkErrorKind {
    Timeout,
    ConnectionRefused,
    DnsResolution,
    Tls,
    Other,
}
\`\`\`

### Change GitLabNetworkError variant:
\`\`\`rust
// Remove:
#[error(\"HTTP error: {0}\")]
Http(#[from] reqwest::Error),

// Change:
#[error(\"Cannot connect to GitLab at {base_url}\")]
GitLabNetworkError {
    base_url: String,
    kind: NetworkErrorKind,
    detail: Option<String>,
},
\`\`\`

## Error Granularity Rationale
Flattening all HTTP errors to detail: Option<String> would lose distinction between timeouts, TLS failures, DNS resolution, and connection resets. NetworkErrorKind preserves actionable error categories without coupling LoreError to any HTTP client. The adapter execute() method classifies errors at the boundary:
- Timeout from asupersync::time::timeout -> NetworkErrorKind::Timeout
- Transport errors -> classified by error type into appropriate kind
- Unknown errors -> NetworkErrorKind::Other

This keeps LoreError client-agnostic while preserving retry decisions based on error type (e.g., retry on timeout but not on TLS).

## CRITICAL: Finding All Affected Call Sites
Removing Http(#[from] reqwest::Error) breaks all code that relies on the implicit From<reqwest::Error> conversion (i.e., ? on reqwest::Error in functions returning LoreError/Result).

To find all affected sites:
\`\`\`bash
# Find all files using reqwest error types or ? on reqwest calls
rg 'reqwest::Error' src/ --files-with-matches
rg '\.send\(\)\.await\?' src/ --files-with-matches
# Also check for direct GitLabNetworkError construction
rg 'GitLabNetworkError' src/ --files-with-matches
\`\`\`

### Known sites to update:
- src/gitlab/client.rs — GitLabNetworkError construction in request(), request_with_headers()
- src/gitlab/graphql.rs — GitLabNetworkError construction in execute_query()
- src/embedding/ollama.rs — may construct errors from reqwest failures

### Pattern for temporary fix:
\`\`\`rust
// Before (implicit From):
let response = self.client.get(&url).send().await?;
// After (explicit conversion — temporary until Phase 2 rewrites these):
let response = self.client.get(&url).send().await
    .map_err(|e| LoreError::GitLabNetworkError {
        base_url: url.clone(),
        kind: NetworkErrorKind::Other,
        detail: Some(format!(\"{e:?}\")),
    })?;
\`\`\`

These temporary conversions are throwaway work — Phase 2 rewrites all these call sites to use the adapter. But they must compile after Phase 0d.

## Files Changed
- src/core/error.rs (~15 LOC changed)
- src/gitlab/client.rs (update GitLabNetworkError construction sites)
- src/gitlab/graphql.rs (same)
- src/embedding/ollama.rs (same, if any)

## Testing
- cargo check --all-targets (MUST compile cleanly)
- cargo clippy --all-targets -- -D warnings
- cargo test (existing tests should pass — verify no tests match on Http variant or source field)


Integration test: full Phase B sync pipeline

## Background

This integration test proves the full Phase B sync pipeline works end-to-end. Since Gates 1 and 2 are already implemented and closed, this test validates that the complete pipeline — including Gate 4 mr_diffs draining — works together.

## Codebase Context

- **Gates 1-2 FULLY IMPLEMENTED (CLOSED):** resource events fetch, closes_issues API, system note parsing (note_parser.rs), entity_references extraction (references.rs)
- **Gate 4 in progress:** migration 016 (mr_file_changes), fetch_mr_diffs, drain_mr_diffs — already wired in orchestrator (lines 708-726, 1514+)
- **26 migrations exist** (001-026). LATEST_SCHEMA_VERSION = 26. In-memory DB must run all 26.
- Orchestrator has drain_resource_events() (line 932), drain_mr_closes_issues() (line 1254), and drain_mr_diffs() (line 1514).
- wiremock crate used in existing tests (check dev-dependencies in Cargo.toml)
- src/core/dependent_queue.rs: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- IngestProjectResult and IngestMrProjectResult track counts for all drain phases

## Approach

Create tests/phase_b_integration.rs:

### Test Setup

1. In-memory SQLite DB with all 26 migrations (001-026)
2. wiremock mock server with:
   - /api/v4/projects/:id/issues — 2 test issues
   - /api/v4/projects/:id/merge_requests — 1 test MR
   - /api/v4/projects/:id/issues/:iid/resource_state_events — state events
   - /api/v4/projects/:id/issues/:iid/resource_label_events — label events
   - /api/v4/projects/:id/merge_requests/:iid/resource_state_events — merge event with source_merge_request_iid
   - /api/v4/projects/:id/merge_requests/:iid/closes_issues — linked issues
   - /api/v4/projects/:id/merge_requests/:iid/diffs — file changes
   - /api/v4/projects/:id/issues/:iid/discussions — discussion with system note "mentioned in !1"
3. Config with fetch_resource_events=true and fetch_mr_file_changes=true
4. Use dependent_concurrency=1 to avoid timing issues

### Test Flow

```rust
#[tokio::test]
async fn test_full_phase_b_pipeline() {
    // 1. Set up mock server + DB with all 26 migrations
    // 2. Run ingest issues + MRs (orchestrator functions)
    // 3. Verify pending_dependent_fetches enqueued: resource_events, mr_closes_issues, mr_diffs
    // 4. Drain all dependent fetch queues
    // 5. Assert: resource_state_events populated (count > 0)
    // 6. Assert: resource_label_events populated (count > 0)
    // 7. Assert: entity_references has closes ref with source_method='api'
    // 8. Assert: entity_references has mentioned ref with source_method='note_parse'
    // 9. Assert: mr_file_changes populated from diffs API
    // 10. Assert: pending_dependent_fetches fully drained (no stuck locks)
}
```

### Assertions (SQL)

```sql
SELECT COUNT(*) FROM resource_state_events  -- > 0
SELECT COUNT(*) FROM resource_label_events  -- > 0
SELECT COUNT(*) FROM entity_references WHERE reference_type = 'closes' AND source_method = 'api'  -- >= 1
SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'  -- >= 1
SELECT COUNT(*) FROM mr_file_changes  -- > 0
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL  -- = 0
```

## Acceptance Criteria

- [ ] Test creates DB with all 26 migrations, mocks, and runs full pipeline
- [ ] resource_state_events and resource_label_events populated
- [ ] entity_references has closes ref (source_method='api') and mentioned ref (source_method='note_parse')
- [ ] mr_file_changes populated from diffs mock
- [ ] pending_dependent_fetches fully drained (no stuck locks, no retryable jobs)
- [ ] Test runs in < 10 seconds
- [ ] `cargo test --test phase_b_integration` passes

## Files

- CREATE: tests/phase_b_integration.rs

## TDD Anchor

RED: Write test with all assertions — should pass if all Gates are wired correctly.

GREEN: If anything fails, it indicates a missing orchestrator connection — fix the wiring.

VERIFY: cargo test --test phase_b_integration -- --nocapture

## Edge Cases

- Paginated mock responses: include Link header for multi-page responses
- Empty pages: verify graceful handling
- Use dependent_concurrency=1 to avoid timing issues in test environment
- Stale lock reclaim: test that locks older than stale_lock_minutes are reclaimed
- If Gate 4 drain_mr_diffs is not fully wired yet, the mr_file_changes assertion will fail — this is the intended RED signal

## Dependency Context

- **bd-8t4 (resource_state_events extraction)**: CLOSED. Provides drain_resource_events() which populates resource_state_events and resource_label_events tables.
- **bd-3ia (closes_issues)**: CLOSED. Provides drain_mr_closes_issues() which populates entity_references with reference_type='closes', source_method='api'.
- **bd-1ji (note parsing)**: CLOSED. Provides note_parser.rs which extracts "mentioned in !N" patterns and stores as entity_references with source_method='note_parse'.
- **dependent_queue.rs**: Provides the claim/complete/fail lifecycle. All three drain functions use this.
- **orchestrator.rs**: Contains all drain functions. drain_mr_diffs() at line 1514+ populates mr_file_changes.


Verification: quality gates, query plan check, real-world validation

## Background

Post-implementation verification checkpoint. Runs after all code beads complete to validate the full scoring model works correctly against real data, not just test fixtures.

## Approach

Execute 8 verification steps in order. Each step has a binary pass/fail outcome.

### Step 1: Compiler check
```bash
cargo check --all-targets
```
Pass: exit 0

### Step 2: Clippy
```bash
cargo clippy --all-targets -- -D warnings
```
Pass: exit 0

### Step 3: Formatting
```bash
cargo fmt --check
```
Pass: exit 0

### Step 4: Test suite
```bash
cargo test -p lore
```
Pass: all tests green, including 31 new decay/scoring tests

### Step 5: UBS scan
```bash
ubs src/cli/commands/who.rs src/core/config.rs src/core/db.rs
```
Pass: exit 0

### Step 6: Query plan verification (manual)
Run against real database:
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx -vvv 2>&1 | grep -i "query plan"
```
Or use sqlite3 CLI with EXPLAIN QUERY PLAN on the expert SQL (both exact and prefix modes).

Pass criteria (6 checks):
- matched_notes_raw branch 1 uses existing new_path index
- matched_notes_raw branch 2 uses idx_notes_old_path_author
- matched_file_changes_raw uses idx_mfc_new_path_project_mr and idx_mfc_old_path_project_mr
- reviewer_participation uses idx_notes_diffnote_discussion_author
- mr_activity CTE joins merge_requests via primary key from matched_file_changes
- Path resolution probes (old_path leg) use idx_notes_old_path_project_created
Document observed plan as SQL comment near the CTE.

### Step 7: Performance baseline (manual)
```bash
time cargo run --release -- who --path MeasurementQualityDialog.tsx
time cargo run --release -- who --path src/
time cargo run --release -- who --path Dialog.tsx
```
Pass criteria (soft SLOs):
- Exact path: p95 < 200ms
- Prefix: p95 < 300ms
- Suffix: p95 < 500ms
Record timings as SQL comment for future regression reference.

### Step 8: Real-world validation
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx
cargo run --release -- who --path MeasurementQualityDialog.tsx --explain-score
cargo run --release -- who --path MeasurementQualityDialog.tsx --as-of 2025-06-01
cargo run --release -- who --path MeasurementQualityDialog.tsx --all-history
```
Pass criteria:
- [ ] Recency discounting visible (recent authors rank above old reviewers)
- [ ] --explain-score components sum to total (within f64 tolerance)
- [ ] --as-of produces identical results on repeated runs
- [ ] Assigned-only reviewers rank below participated reviewers on same MR
- [ ] Known renamed file path resolves and credits old expertise
- [ ] LGTM-only reviewers classified as assigned-only
- [ ] Closed MRs at ~50% contribution visible via --explain-score

## Acceptance Criteria
- [ ] Steps 1-5 pass (exit 0)
- [ ] Step 6: query plan documented with all 6 index usage points confirmed
- [ ] Step 7: timing baselines recorded
- [ ] Step 8: all 7 real-world checks pass

## Files
- All files modified by child beads (read-only verification)
- Add SQL comments near CTE with observed EXPLAIN QUERY PLAN output

## Edge Cases
- SQLite planner may choose different plans across versions — document version
- Timing varies by hardware — record machine specs alongside baselines
- Real DB may have NULL merged_at on old MRs — state-aware fallback handles this


Implement pending discussion queue

## Background
The pending discussion queue tracks discussions that need to be fetched from GitLab. When an issue or MR is updated, its discussions may need re-fetching. This queue is separate from dirty_sources (which tracks entities needing document regeneration) — it tracks entities needing API calls to GitLab. The queue uses the same backoff pattern as dirty_sources for consistency.

## Approach
Create `src/ingestion/discussion_queue.rs`:

```rust
use crate::core::backoff::compute_next_attempt_at;

/// Noteable type for discussion queue.
#[derive(Debug, Clone, Copy)]
pub enum NoteableType {
    Issue,
    MergeRequest,
}

impl NoteableType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Issue => "Issue",
            Self::MergeRequest => "MergeRequest",
        }
    }
}

pub struct PendingFetch {
    pub project_id: i64,
    pub noteable_type: NoteableType,
    pub noteable_iid: i64,
    pub attempt_count: i32,
}

/// Queue a discussion fetch. ON CONFLICT DO UPDATE resets backoff (consistent with dirty_sources).
pub fn queue_discussion_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Get next batch of pending fetches (WHERE next_attempt_at IS NULL OR <= now).
pub fn get_pending_fetches(conn: &Connection, limit: usize) -> Result<Vec<PendingFetch>>;

/// Mark fetch complete (remove from queue).
pub fn complete_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Record fetch error with backoff.
pub fn record_fetch_error(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
    error: &str,
) -> Result<()>;
```

## Acceptance Criteria
- [ ] queue_discussion_fetch uses ON CONFLICT DO UPDATE (consistent with dirty_sources pattern)
- [ ] Re-queuing resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL
- [ ] get_pending_fetches respects next_attempt_at backoff
- [ ] get_pending_fetches returns entries ordered by queued_at ASC
- [ ] complete_fetch removes entry from queue
- [ ] record_fetch_error increments attempt_count, computes next_attempt_at via shared backoff
- [ ] NoteableType.as_str() returns "Issue" or "MergeRequest" (matches DB CHECK constraint)
- [ ] `cargo test discussion_queue` passes

## Files
- `src/ingestion/discussion_queue.rs` — new file
- `src/ingestion/mod.rs` — add `pub mod discussion_queue;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_queue_and_get` — queue entry, get returns it
- `test_requeue_resets_backoff` — queue, error, re-queue -> attempt_count=0
- `test_backoff_respected` — entry with future next_attempt_at not returned
- `test_complete_removes` — complete_fetch removes entry
- `test_error_increments_attempts` — error -> attempt_count=1, next_attempt_at set
GREEN: Implement all functions
VERIFY: `cargo test discussion_queue`

## Edge Cases
- Queue same (project_id, noteable_type, noteable_iid) twice: ON CONFLICT resets state
- NoteableType must match DB CHECK constraint exactly ("Issue", "MergeRequest" — capitalized)
- Empty queue: get_pending_fetches returns empty Vec

Implemented discussion_queue with queue/get/complete/record_error + 6 tests


Parse system notes for cross-reference patterns

## Background
System notes contain cross-reference patterns like 'mentioned in !{iid}', 'closed by !{iid}', etc. This is best-effort, English-only extraction that supplements the structured API data from bd-3ia and bd-8t4. Runs as a local post-processing step (no API calls).

## Approach
Create src/core/note_parser.rs:

```rust
use regex::Regex;
use lazy_static::lazy_static;

/// A parsed cross-reference from a system note.
pub struct ParsedCrossRef {
    pub reference_type: String,    // "mentioned" | "closes"
    pub target_entity_type: String, // "issue" | "merge_request"
    pub target_iid: i64,
    pub target_project_path: Option<String>,  // None = same project
}

lazy_static! {
    static ref MENTIONED_RE: Regex = Regex::new(
        r"mentioned in (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
    static ref CLOSED_BY_RE: Regex = Regex::new(
        r"closed by (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
}

/// Parse a system note body for cross-references.
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef>

/// Extract cross-references from all system notes and insert into entity_references.
/// Queries notes WHERE is_system = 1, parses body text, resolves to entity_references.
pub fn extract_refs_from_system_notes(
    conn: &Connection,
    project_id: i64,
) -> Result<ExtractResult>

pub struct ExtractResult {
    pub inserted: usize,
    pub skipped_unresolvable: usize,
    pub parse_failures: usize,   // logged at debug level
}
```

Sigil mapping: `#` = issue, `!` = merge_request

Resolution logic:
1. If target_project_path is None (same project): look up entity by iid in local DB → set target_entity_id
2. If target_project_path is Some: check if project is synced locally
   - If yes: resolve to local entity id
   - If no: store as unresolved (target_entity_id=NULL, target_project_path=path, target_entity_iid=iid)

Insert with source_method='system_note_parse', INSERT OR IGNORE for dedup.

Call after drain_dependent_queue and extract_refs_from_state_events in the sync pipeline.

## Acceptance Criteria
- [ ] 'mentioned in !123' → mentioned ref, target=MR iid 123
- [ ] 'mentioned in #456' → mentioned ref, target=issue iid 456
- [ ] 'mentioned in group/project!789' → cross-project mentioned ref
- [ ] 'closed by !123' → closes ref
- [ ] Cross-project refs stored as unresolved when target project not synced
- [ ] source_method = 'system_note_parse'
- [ ] Parse failures logged at debug level (not errors)
- [ ] Idempotent (INSERT OR IGNORE)
- [ ] Only processes is_system=1 notes

## Files
- src/core/note_parser.rs (new)
- src/core/mod.rs (add `pub mod note_parser;`)
- src/cli/commands/sync.rs (call after other ref extraction steps)

## TDD Loop
RED: tests/note_parser_tests.rs:
- `test_parse_mentioned_in_mr` - "mentioned in !567" → ParsedCrossRef { mentioned, merge_request, 567 }
- `test_parse_mentioned_in_issue` - "mentioned in #234" → ParsedCrossRef { mentioned, issue, 234 }
- `test_parse_mentioned_cross_project` - "mentioned in group/repo!789" → with project path
- `test_parse_closed_by_mr` - "closed by !567" → ParsedCrossRef { closes, merge_request, 567 }
- `test_parse_multiple_refs` - note with two mentions → two refs
- `test_parse_no_refs` - "Updated the description" → empty vec
- `test_extract_refs_from_system_notes_integration` - seed DB with system notes, verify entity_references created

GREEN: Implement regex patterns and extraction logic

VERIFY: `cargo test note_parser -- --nocapture`

## Edge Cases
- Non-English GitLab instances: "ajouté l'étiquette ~bug" won't match — this is accepted limitation, logged at debug
- Multi-level group paths: "mentioned in top/sub/project#123" — regex needs to handle arbitrary depth ([\w\-]+(?:/[\w\-]+)+)
- Note body may contain markdown links that look like refs: "[#123](url)" — the regex should handle this correctly since the prefix "mentioned in" is required
- Same ref mentioned multiple times in same note — dedup via INSERT OR IGNORE
- Note may reference itself (e.g., system note on issue #123 says "mentioned in #123") — technically valid, store it

Completed: parse_cross_refs regex parser, extract_refs_from_system_notes DB function, wired into orchestrator. 17 tests passing.


Implement FTS5 search function and query sanitization

## Background
FTS5 search is the core lexical retrieval engine. It wraps SQLite's FTS5 with safe query parsing that prevents user input from causing SQL syntax errors, while preserving useful features like prefix search for type-ahead. The search function returns ranked results with BM25 scores and contextual snippets. This module is the Gate A search backbone and also provides fallback search when Ollama is unavailable in Gate B.

## Approach
Create `src/search/` module with `mod.rs` and `fts.rs` per PRD Section 3.1-3.2.

**src/search/mod.rs:**
```rust
mod fts;
mod filters;
// Later beads add: mod vector; mod hybrid; mod rrf;
pub use fts::{search_fts, to_fts_query, FtsResult, FtsQueryMode, generate_fallback_snippet, get_result_snippet};
```

**src/search/fts.rs — key functions:**

1. `to_fts_query(raw: &str, mode: FtsQueryMode) -> String`
   - Safe mode: wrap each token in quotes, escape internal quotes, preserve trailing * on alphanumeric tokens
   - Raw mode: pass through unchanged

2. `search_fts(conn: &Connection, query: &str, limit: usize, mode: FtsQueryMode) -> Result<Vec<FtsResult>>`
   - Uses `bm25(documents_fts)` for ranking
   - Uses `snippet(documents_fts, 1, '<mark>', '</mark>', '...', 64)` for context
   - Column index 1 = content_text (0=title)

3. `generate_fallback_snippet(content_text: &str, max_chars: usize) -> String`
   - For semantic-only results without FTS snippets
   - Uses `truncate_utf8()` for safe byte boundaries

4. `truncate_utf8(s: &str, max_bytes: usize) -> &str`
   - Walks backward from max_bytes to find nearest char boundary

5. `get_result_snippet(fts_snippet: Option<&str>, content_text: &str) -> String`
   - Prefers FTS snippet, falls back to truncated content

Update `src/lib.rs`: add `pub mod search;`

## Acceptance Criteria
- [ ] Porter stemming works: search "searching" matches document containing "search"
- [ ] Prefix search works: `auth*` matches "authentication"
- [ ] Empty query returns empty Vec (no error)
- [ ] Special characters don't cause FTS5 errors: `-`, `"`, `:`, `*`
- [ ] Query `"-DWITH_SSL"` returns results (dash not treated as NOT operator)
- [ ] Query `C++` returns results (special chars preserved in quotes)
- [ ] Safe mode preserves trailing `*` on alphanumeric tokens: `auth*` -> `"auth"*`
- [ ] Raw mode passes query unchanged
- [ ] BM25 scores returned (lower = better match)
- [ ] Snippets contain `<mark>` tags around matches
- [ ] `generate_fallback_snippet` truncates at word boundary, appends "..."
- [ ] `truncate_utf8` never panics on multi-byte codepoints
- [ ] `cargo test fts` passes

## Files
- `src/search/mod.rs` — new file (module root)
- `src/search/fts.rs` — new file (FTS5 search + query sanitization)
- `src/lib.rs` — add `pub mod search;`

## TDD Loop
RED: Tests in `fts.rs` `#[cfg(test)] mod tests`:
- `test_safe_query_basic` — "auth error" -> `"auth" "error"`
- `test_safe_query_prefix` — "auth*" -> `"auth"*`
- `test_safe_query_special_chars` — "C++" -> `"C++"`
- `test_safe_query_dash` — "-DWITH_SSL" -> `"-DWITH_SSL"`
- `test_safe_query_quotes` — `he said "hello"` -> escaped
- `test_raw_mode_passthrough` — raw query unchanged
- `test_empty_query` — returns empty vec
- `test_truncate_utf8_emoji` — truncate mid-emoji walks back
- `test_fallback_snippet_word_boundary` — truncates at space
GREEN: Implement to_fts_query, search_fts, helpers
VERIFY: `cargo test fts`

## Edge Cases
- Query with only whitespace: treated as empty, returns empty
- Query with only special characters: quoted, may return no results (not an error)
- Very long query (1000+ chars): works but may be slow (no explicit limit)
- FTS5 snippet returns empty string: fallback to truncated content_text
- Non-alphanumeric prefix: `C++*` — NOT treated as prefix (special chars present)

Completed: to_fts_query (safe/raw modes), search_fts with BM25+snippets, generate_fallback_snippet, get_result_snippet, truncate_utf8 reuse, 13 tests pass