Wire up hybrid search: FTS5 + vector + RRF ranking

## Problem
lore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.

## Current State (Verified 2026-02-12)

### Backend: COMPLETE
- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation
- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier
- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)
- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector
- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint
- All exported from src/search/mod.rs:7-14

### CLI: BROKEN
- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)
- Line 76: `let actual_mode = "lexical";` — hardcoded
- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical
- Line 161: calls `search_fts()` directly instead of `search_hybrid()`
- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking
- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)
- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)

### Entry Point
- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758
- main.rs is 2579 lines total

## Actual Work Required

### Step 1: Make run_search async
Change `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.
Update handle_search call site (main.rs:1758) to `.await`.

### Step 2: Create OllamaClient when mode != lexical
Pattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:
```rust
let client = if actual_mode != SearchMode::Lexical {
    let ollama_cfg = &config.embedding;
    Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))
} else {
    None
};
```

### Step 3: Replace manual FTS+filter+rank with search_hybrid call
Replace lines 161-172 (search_fts + rank_rrf) with:
```rust
let (hybrid_results, mut hybrid_warnings) = search_hybrid(
    &conn,
    client.as_ref(),
    query,
    actual_mode,
    &filters,
    fts_mode,
).await?;
warnings.append(&mut hybrid_warnings);
```

### Step 4: Map HybridResult to SearchResultDisplay
HybridResult (src/search/hybrid.rs:39-45) has these fields:
```rust
pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,           // combined score
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,
}
```
NOTE: HybridResult has NO `snippet` field and NO `normalized_score` field. `score` is the combined score. The `snippet` must still be obtained from the FTS results or from `get_result_snippet()`.

SearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).
Keep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), but adapt to use HybridResult instead of RrfResult:
```rust
// Map hybrid results for lookup
let hybrid_map: HashMap<i64, &HybridResult> =
    hybrid_results.iter().map(|r| (r.document_id, r)).collect();

// For each hydrated row:
let hr = hybrid_map.get(&row.document_id);
let explain_data = if explain {
    hr.map(|r| ExplainData {
        vector_rank: r.vector_rank,
        fts_rank: r.fts_rank,
        rrf_score: r.rrf_score,
    })
} else { None };
// score: hr.map(|r| r.score).unwrap_or(0.0)
```

For snippets: search_hybrid calls search_fts internally, but does NOT return snippets. You need to either:
(a) Call search_fts separately just for snippets, or
(b) Modify search_hybrid to also return a snippet_map — preferred if touching hybrid.rs is in scope.
Simpler approach: keep the existing `search_fts()` call for snippets, use hybrid for ranking. The FTS call is fast (<50ms) and avoids modifying the already-complete hybrid.rs.

### Step 5: Determine actual_mode from config + CLI flag
```rust
let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);
// search_hybrid handles graceful degradation internally
```

## Signatures for Reference

```rust
// src/search/hybrid.rs:47
pub async fn search_hybrid(
    conn: &Connection,
    client: Option<&OllamaClient>,
    query: &str,
    mode: SearchMode,
    filters: &SearchFilters,
    fts_mode: FtsQueryMode,
) -> Result<(Vec<HybridResult>, Vec<String>)>

// src/search/hybrid.rs:39
pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,
}

// src/search/mod.rs exports
pub use hybrid::{HybridResult, SearchMode, search_hybrid};
pub use rrf::{RrfResult, rank_rrf};
pub use vector::{VectorResult, search_vector};

// src/embedding/ollama.rs:103
pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
```

## TDD Loop
RED: Add test in src/search/hybrid.rs:
- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning
- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist

GREEN: Wire search.rs to call search_hybrid() as described above

VERIFY:
```bash
cargo test search:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'
# Should return "hybrid" (or "lexical" with warning if Ollama is down)
```

## Edge Cases
- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`
- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully
- Query embedding returns all zeros: should still return FTS results
- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)
- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector
- OllamaConfig missing from config: check `config.embedding` exists before constructing client
- Snippet handling: HybridResult has no snippet field — must obtain snippets from a separate search_fts call or from get_result_snippet() with content_text fallback

## Files to Modify
- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)
- src/main.rs:1758 — add .await to run_search call (already in async context)

## Files NOT to Modify (already complete)
- src/search/hybrid.rs
- src/search/vector.rs
- src/search/rrf.rs
- src/embedding/ollama.rs


[CP0] GitLab API client with rate limiting

## Background

The GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.

Reference: docs/prd/checkpoint-0.md section "GitLab Client"

## Approach

**src/gitlab/client.ts:**
```typescript
export class GitLabClient {
  private baseUrl: string;
  private token: string;
  private rateLimiter: RateLimiter;

  constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {
    this.baseUrl = options.baseUrl.replace(/\/$/, '');
    this.token = options.token;
    this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);
  }

  async getCurrentUser(): Promise<GitLabUser>
  async getProject(pathWithNamespace: string): Promise<GitLabProject>
  private async request<T>(path: string, options?: RequestInit): Promise<T>
}

class RateLimiter {
  private lastRequest = 0;
  private minInterval: number;

  constructor(requestsPerSecond: number) {
    this.minInterval = 1000 / requestsPerSecond;
  }

  async acquire(): Promise<void> {
    // Wait if too soon since last request
    // Add 0-50ms jitter
  }
}
```

**src/gitlab/types.ts:**
```typescript
export interface GitLabUser {
  id: number;
  username: string;
  name: string;
}

export interface GitLabProject {
  id: number;
  path_with_namespace: string;
  default_branch: string;
  web_url: string;
  created_at: string;
  updated_at: string;
}
```

**Integration tests with MSW (Mock Service Worker):**
Set up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path

## Acceptance Criteria

- [ ] getCurrentUser() returns GitLabUser with id, username, name
- [ ] getProject("group/project") URL-encodes path correctly
- [ ] 401 response throws GitLabAuthError
- [ ] 404 response throws GitLabNotFoundError
- [ ] 429 response throws GitLabRateLimitError with retryAfter from header
- [ ] Network failure throws GitLabNetworkError
- [ ] Rate limiter enforces minimum interval between requests
- [ ] Rate limiter adds random jitter (0-50ms)
- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)

## Files

CREATE:
- src/gitlab/client.ts
- src/gitlab/types.ts
- tests/integration/gitlab-client.test.ts
- tests/fixtures/mock-responses/gitlab-user.json
- tests/fixtures/mock-responses/gitlab-project.json

## TDD Loop

RED:
```typescript
// tests/integration/gitlab-client.test.ts
describe('GitLab Client', () => {
  it('authenticates with valid PAT')
  it('returns 401 for invalid PAT')
  it('fetches project by path')
  it('handles rate limiting (429) with Retry-After')
  it('respects rate limit (requests per second)')
  it('adds jitter to rate limiting')
})
```

GREEN: Implement client.ts and types.ts

VERIFY: `npm run test -- tests/integration/gitlab-client.test.ts`

## Edge Cases

- Path with special characters (spaces, slashes) must be URL-encoded
- Retry-After header may be missing - default to 60s
- Network timeout should be handled (use AbortController)
- Rate limiter jitter prevents multiple clients syncing in lockstep
- baseUrl trailing slash should be stripped


Add --issue, --mr, -p, --preflight-only CLI flags and SyncOptions extensions with validation

## Background
Surgical sync is invoked via `lore sync --issue 123 --mr 456 -p myproject`. This bead adds the CLI flags to `SyncArgs` (clap struct), extends `SyncOptions` with surgical fields, and wires them together in `handle_sync_cmd` with full validation. This is the user-facing entry point for the entire surgical sync feature.

The existing `SyncArgs` struct at lines 760-805 of `src/cli/mod.rs` defines all CLI flags for `lore sync`. `SyncOptions` at lines 20-29 of `src/cli/commands/sync.rs` is the runtime options struct passed to `run_sync`. `handle_sync_cmd` at lines 2070-2096 of `src/main.rs` bridges CLI args to SyncOptions and calls `run_sync`.

## Approach

### Step 1: Add flags to SyncArgs (src/cli/mod.rs, struct SyncArgs at line ~760)

Add after the existing `timings` field:

```rust
/// Surgically sync specific issues by IID (repeatable, must be positive)
#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]
pub issue: Vec<u64>,

/// Surgically sync specific merge requests by IID (repeatable, must be positive)
#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]
pub mr: Vec<u64>,

/// Scope to a single project (required when --issue or --mr is used, falls back to config.defaultProject)
#[arg(short = 'p', long)]
pub project: Option<String>,

/// Validate remote entities exist without any DB content writes. Runs preflight network fetch only.
#[arg(long, default_value_t = false)]
pub preflight_only: bool,
```

**Why u64 with range(1..)**: IIDs are always positive. Parse-time validation gives immediate, clear error messages from clap.

### Step 2: Extend SyncOptions (src/cli/commands/sync.rs, struct SyncOptions at line ~20)

Add fields:

```rust
pub issue_iids: Vec<u64>,
pub mr_iids: Vec<u64>,
pub project: Option<String>,
pub preflight_only: bool,
```

Add helper:

```rust
impl SyncOptions {
    pub const MAX_SURGICAL_TARGETS: usize = 100;

    pub fn is_surgical(&self) -> bool {
        !self.issue_iids.is_empty() || !self.mr_iids.is_empty()
    }
}
```

### Step 3: Wire in handle_sync_cmd (src/main.rs, function handle_sync_cmd at line ~2070)

After existing SyncOptions construction (~line 2088):

1. **Dedup IIDs** before constructing options:
```rust
let mut issue_iids = args.issue;
let mut mr_iids = args.mr;
issue_iids.sort_unstable();
issue_iids.dedup();
mr_iids.sort_unstable();
mr_iids.dedup();
```

2. **Add new fields** to the SyncOptions construction.

3. **Validation** (after options creation, before calling run_sync):
- Hard cap: `issue_iids.len() + mr_iids.len() > MAX_SURGICAL_TARGETS` → error with count
- Project required: if `is_surgical()`, use `config.effective_project(options.project.as_deref())`. If None → error saying `-p` or `defaultProject` is required
- Incompatible flags: `--full` + surgical → error
- Embed leakage guard: `--no-docs` without `--no-embed` in surgical mode → error (stale embeddings for regenerated docs)
- `--preflight-only` requires surgical mode → error if not `is_surgical()`

## Acceptance Criteria
- [ ] `lore sync --issue 123` parses correctly (issue_iids = [123])
- [ ] `lore sync --issue 123 --issue 456` produces deduplicated sorted vec
- [ ] `lore sync --mr 789` parses correctly
- [ ] `lore sync --issue 0` rejected at parse time by clap (range 1..)
- [ ] `lore sync --issue -1` rejected at parse time by clap (u64 parse failure)
- [ ] `lore sync -p myproject --issue 1` sets project = Some("myproject")
- [ ] `lore sync --preflight-only --issue 1 -p proj` sets preflight_only = true
- [ ] `SyncOptions::is_surgical()` returns true when issue_iids or mr_iids is non-empty
- [ ] `SyncOptions::is_surgical()` returns false when both vecs are empty
- [ ] `SyncOptions::MAX_SURGICAL_TARGETS` is 100
- [ ] Validation: `--issue 1` without `-p` and no defaultProject → error mentioning `-p`
- [ ] Validation: `--issue 1` without `-p` but with defaultProject in config → uses defaultProject (no error)
- [ ] Validation: `--full --issue 1 -p proj` → incompatibility error
- [ ] Validation: `--no-docs --issue 1 -p proj` (without --no-embed) → embed leakage error
- [ ] Validation: `--no-docs --no-embed --issue 1 -p proj` → accepted
- [ ] Validation: `--preflight-only` without --issue/--mr → error
- [ ] Validation: >100 combined targets → hard cap error
- [ ] Normal `lore sync` (without --issue/--mr) still works identically
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/mod.rs (add fields to SyncArgs, ~line 805)
- MODIFY: src/cli/commands/sync.rs (extend SyncOptions + is_surgical + MAX_SURGICAL_TARGETS)
- MODIFY: src/main.rs (wire fields + validation in handle_sync_cmd)

## TDD Anchor
RED: Write tests in `src/cli/commands/sync.rs` (in a `#[cfg(test)] mod tests` block):

```rust
#[cfg(test)]
mod tests {
    use super::*;

    fn default_options() -> SyncOptions {
        SyncOptions {
            full: false,
            no_status: false,
            no_docs: false,
            no_embed: false,
            timings: false,
            issue_iids: vec![],
            mr_iids: vec![],
            project: None,
            preflight_only: false,
        }
    }

    #[test]
    fn is_surgical_with_issues() {
        let opts = SyncOptions { issue_iids: vec![1], ..default_options() };
        assert!(opts.is_surgical());
    }

    #[test]
    fn is_surgical_with_mrs() {
        let opts = SyncOptions { mr_iids: vec![10], ..default_options() };
        assert!(opts.is_surgical());
    }

    #[test]
    fn is_surgical_empty() {
        let opts = default_options();
        assert!(!opts.is_surgical());
    }

    #[test]
    fn max_surgical_targets_is_100() {
        assert_eq!(SyncOptions::MAX_SURGICAL_TARGETS, 100);
    }
}
```

GREEN: Add the fields and `is_surgical()` method.
VERIFY: `cargo test is_surgical && cargo test max_surgical_targets`

Additional validation tests (in integration or as unit tests on a `validate_surgical_options` helper if extracted):
- `preflight_only_requires_surgical` — SyncOptions with preflight_only=true, empty iids → error
- `surgical_no_docs_requires_no_embed` — SyncOptions with no_docs=true, no_embed=false, is_surgical=true → error
- `surgical_incompatible_with_full` — SyncOptions with full=true, is_surgical=true → error

## Edge Cases
- Clap `ArgAction::Append` allows `--issue 1 --issue 2` but NOT `--issue 1,2` (no value_delimiter). This is intentional — comma-separated values are ambiguous and error-prone.
- Duplicate IIDs like `--issue 123 --issue 123` are handled by dedup in handle_sync_cmd, not rejected.
- The `effective_project` method on Config (line 309 of config.rs) already handles the `-p` / defaultProject fallback: `cli_project.or(self.default_project.as_deref())`.
- The `-p` short flag does not conflict with any existing SyncArgs flags.

## Dependency Context
This is a leaf dependency with no upstream blockers. Can be done in parallel with bd-1sc6, bd-159p, bd-tiux. Downstream bead bd-1i4i (orchestrator) reads these fields to dispatch surgical vs standard sync.


Extend 'lore stats --check' for event table integrity and queue health

## Background
The existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.

## Approach
Extend src/cli/commands/stats.rs check mode:

**New checks:**

1. Event FK integrity:
```sql
-- Orphaned state events (issue_id points to non-existent issue)
SELECT COUNT(*) FROM resource_state_events rse
WHERE rse.issue_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);
-- (repeat for merge_request_id, and for label + milestone event tables)
```

2. Queue health:
```sql
-- Pending jobs by type
SELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;
-- Stuck locks (locked_at older than 5 minutes)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;
-- Retryable jobs (attempts > 0, not locked)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;
-- Max attempts (jobs that may be permanently failing)
SELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;
```

3. Human output per check: PASS / WARN / FAIL with counts
```
Event FK integrity:     PASS (0 orphaned events)
Queue health:           WARN (3 stuck locks, 12 retryable jobs)
```

4. Robot JSON: structured health report
```json
{
  "event_integrity": {
    "status": "pass",
    "orphaned_state_events": 0,
    "orphaned_label_events": 0,
    "orphaned_milestone_events": 0
  },
  "queue_health": {
    "status": "warn",
    "pending_by_type": {"resource_events": 5, "mr_closes_issues": 2},
    "stuck_locks": 3,
    "retryable_jobs": 12,
    "max_attempts_by_type": {"resource_events": 5}
  }
}
```

## Acceptance Criteria
- [ ] Detects orphaned events (FK target missing)
- [ ] Detects stuck locks (locked_at older than threshold)
- [ ] Reports retryable job count and max attempts
- [ ] Human output shows PASS/WARN/FAIL per check
- [ ] Robot JSON matches structured schema
- [ ] Graceful when event/queue tables don't exist

## Files
- src/cli/commands/stats.rs (extend check mode)

## TDD Loop
RED: tests/stats_check_tests.rs:
- `test_stats_check_events_pass` - clean data, verify PASS
- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count
- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN
- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count

GREEN: Add the check queries and formatting

VERIFY: `cargo test stats_check -- --nocapture`

## Edge Cases
- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them
- Tables may not exist if migration 011 not applied — check table existence before querying
- Empty queue is PASS (not WARN for "no jobs found")
- Distinguish between "0 stuck locks" (good) and "queue table doesn't exist" (skip check)

Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

Output:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

Files: src/cli/commands/sync-status.ts (update existing)
Done when: Shows cursor positions and counts after ingestion


Vertical slice integration test + SLO verification

## Background
The vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.

## Approach
Create integration tests in crates/lore-tui/tests/:
- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard
- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update
- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed
- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms
- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C
- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s

## Acceptance Criteria
- [ ] Full nav flow test passes without panic
- [ ] Filter re-query test shows updated results
- [ ] No stale data displayed during rapid navigation
- [ ] p95 nav latency < 75ms on M-tier fixtures
- [ ] Zero stuck-input states across 1000 random key sequences
- [ ] Sync cancel acknowledged p95 < 2s
- [ ] All state preserved correctly on back-navigation

## Files
- CREATE: crates/lore-tui/tests/vertical_slice.rs

## TDD Anchor
RED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.
GREEN: Ensure all navigation and state preservation is wired up.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice

## Edge Cases
- Tests need FakeClock and synthetic DB fixtures (not real GitLab)
- ftui test harness required for rendering tests without TTY
- Performance benchmarks may vary by machine — use relative thresholds

## Dependency Context
Requires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.
Requires NavigationStack, TaskSupervisor, DbManager from Phase 1.

Vertical slice integration test complete: 11 tests covering nav flows, stale guards, input fuzz, bootstrap transition, render all screens, performance smoke


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

Implementation:
1. Acquire app lock with heartbeat
2. Create sync_run record (status='running')
3. For each configured project:
   - Call ingestIssues()
   - For each ingested issue, call ingestIssueDiscussions()
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json)
5. Release lock

Flags:
- --type=issues (required)
- --project=PATH (optional, filter to single project)
- --force (override stale lock)

Output: Progress bar, then summary with counts

Files: src/cli/commands/ingest.ts
Tests: tests/integration/sync-runs.test.ts
Done when: Full issue + discussion ingestion works end-to-end


lore brief: situational awareness for topic/module/person

## Background
Composable capstone command. An agent says "I am about to work on auth" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.

## Input Modes
1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate
2. Path: `lore brief --path src/auth/` — who expert internals for path expertise
3. Person: `lore brief --person teernisse` — who workload internals
4. Entity: `lore brief issues 3864` — single entity focus with cross-references

## Section Assembly Architecture

### Reuse existing run_* functions (ship faster, recommended for v1)
Each section calls existing CLI command functions and converts their output.

IMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.

```rust
// In src/cli/commands/brief.rs

use crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};
use crate::cli::commands::who::{run_who, WhoArgs, WhoMode};
use crate::core::config::Config;

pub async fn run_brief(config: &Config, args: BriefArgs) -> Result<BriefResponse> {
    let mut sections_computed = Vec::new();

    // 1. open_issues: reuse list.rs
    // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult>
    // Located at src/cli/commands/list.rs:268
    let open_issues = run_list_issues(config, ListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        // ... scope by topic/path/person based on mode
        ..Default::default()
    })?;
    sections_computed.push("open_issues");

    // 2. active_mrs: reuse list.rs
    // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult>
    // Located at src/cli/commands/list.rs:476
    let active_mrs = run_list_mrs(config, MrListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        ..Default::default()
    })?;
    sections_computed.push("active_mrs");

    // 3. experts: reuse who.rs
    // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun>
    // Located at src/cli/commands/who.rs:276
    let experts = run_who(config, &WhoArgs {
        mode: WhoMode::Expert,
        path: args.path.clone(),
        limit: Some(3),
        ..Default::default()
    })?;
    sections_computed.push("experts");

    // 4. recent_activity: reuse timeline internals
    // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)
    // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs
    // ...etc
}
```

NOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.

### Concrete Function References (src/cli/commands/)
| Module | Function | Signature | Line |
|--------|----------|-----------|------|
| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result<ListResult>` | 268 |
| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result<MrListResult>` | 476 |
| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result<WhoRun>` | 276 |
| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result<SearchResponse>` | 61 |

NOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.

### Section Details
| Section | Source | Limit | Fallback |
|---------|--------|-------|----------|
| open_issues | list.rs with state=opened | 5 | empty array |
| active_mrs | list.rs with state=opened | 5 | empty array |
| experts | who.rs Expert mode | 3 | empty array (no path data) |
| recent_activity | timeline pipeline | 10 events | empty array |
| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |
| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |
| warnings | computed from dates/state | all | empty array |

### Warning Generation
```rust
fn compute_warnings(issues: &[IssueRow]) -> Vec<String> {
    let now = chrono::Utc::now();
    issues.iter().filter_map(|i| {
        let updated = parse_timestamp(i.updated_at)?;
        let days_stale = (now - updated).num_days();
        if days_stale > 30 {
            Some(format!("Issue #{} has no activity for {} days", i.iid, days_stale))
        } else { None }
    }).chain(
        issues.iter().filter(|i| i.assignees.is_empty())
            .map(|i| format!("Issue #{} is unassigned", i.iid))
    ).collect()
}
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "mode": "topic",
    "query": "authentication",
    "summary": "3 open issues, 2 active MRs, top expert: teernisse",
    "open_issues": [{ "iid": 123, "title": "...", "state": "opened", "assignees": [...], "updated_at": "...", "labels": [...] }],
    "active_mrs": [{ "iid": 456, "title": "...", "state": "opened", "author": "...", "draft": false, "updated_at": "..." }],
    "experts": [{ "username": "teernisse", "score": 42, "last_activity": "..." }],
    "recent_activity": [{ "timestamp": "...", "event_type": "state_change", "entity_ref": "issues#123", "summary": "...", "actor": "..." }],
    "unresolved_threads": [{ "discussion_id": "abc", "entity_ref": "issues#123", "started_by": "...", "note_count": 5, "last_note_at": "..." }],
    "related": [{ "iid": 789, "title": "...", "similarity_score": 0.85 }],
    "warnings": ["Issue #3800 has no activity for 45 days"]
  },
  "meta": { "elapsed_ms": 1200, "sections_computed": ["open_issues", "active_mrs", "experts", "recent_activity"] }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Brief {
    /// Free-text topic, entity type, or omit for project-wide brief
    query: Option<String>,
    /// Focus on a file path (who expert mode)
    #[arg(long)]
    path: Option<String>,
    /// Focus on a person (who workload mode)
    #[arg(long)]
    person: Option<String>,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
    /// Maximum items per section
    #[arg(long, default_value = "5")]
    section_limit: usize,
},
```

## TDD Loop
RED: Tests in src/cli/commands/brief.rs:
- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response
- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated
- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments
- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated
- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)
- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist
- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + "No data found" summary

GREEN: Implement brief with section assembly, calling existing run_* functions

VERIFY:
```bash
cargo test brief:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J brief 'throw time' | jq '.data | keys'
cargo run --release -- -J brief 'throw time' | wc -c  # target <12000
```

## Acceptance Criteria
- [ ] lore brief TOPIC returns all sections for free-text topic
- [ ] lore brief --path PATH returns path-focused briefing with experts
- [ ] lore brief --person USERNAME returns person-focused briefing
- [ ] lore brief issues N returns entity-focused briefing
- [ ] Robot mode output under 12000 bytes (~3000 tokens)
- [ ] Each section degrades gracefully if its data source is unavailable
- [ ] summary field is auto-generated one-liner from section counts
- [ ] warnings detect: stale issues (>30d), unassigned, no due date
- [ ] Performance: <2s total (acceptable since composing multiple queries)
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Topic with zero matches: return empty sections + "No data found for this topic" summary
- Path that nobody has touched: experts empty, related may still have results
- Person not found in DB: exit code 17 with suggestion
- All sections empty: still return valid JSON with empty arrays
- Very broad topic ("the"): may return too many results — each section respects its limit cap
- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed

## Dependencies
- Hybrid search (bd-1ksf) for topic relevance ranking
- lore who (already shipped) for expertise
- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)
- Timeline pipeline (already shipped) for recent activity

## Dependency Context
- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.
- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.

## Files to Create/Modify
- NEW: src/cli/commands/brief.rs
- src/cli/commands/mod.rs (add pub mod brief; re-export)
- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)
- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals

Implemented lore brief: composable situational awareness command. 3 modes (topic/path/person), 7 sections (open_issues, active_mrs, experts, recent_activity, unresolved_threads, related, warnings). 7 unit tests, robot-docs, autocorrect, graceful degradation.


Register 'lore timeline' command with all flags

## Background

This bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).

## Codebase Context

- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs
- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching
- Handler dispatch in `src/main.rs` match on Commands:: variants
- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output
- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.
- No timeline module exists yet — this bead creates the CLI entry point only

## Approach

### 1. TimelineArgs struct (`src/cli/mod.rs`):

```rust
/// Show a chronological timeline of events matching a query
#[derive(Parser, Debug)]
pub struct TimelineArgs {
    /// Search query (keywords to find in issues, MRs, and discussions)
    pub query: String,

    /// Scope to a specific project (fuzzy match)
    #[arg(short = 'p', long)]
    pub project: Option<String>,

    /// Only show events after this date (e.g. "6m", "2w", "2024-01-01")
    #[arg(long)]
    pub since: Option<String>,

    /// Cross-reference expansion depth (0 = no expansion)
    #[arg(long, default_value = "1")]
    pub depth: usize,

    /// Also follow 'mentioned' edges during expansion (high fan-out)
    #[arg(long = "expand-mentions")]
    pub expand_mentions: bool,

    /// Maximum number of events to display
    #[arg(short = 'n', long = "limit", default_value = "100")]
    pub limit: usize,
}
```

### 2. Commands enum variant:

```rust
/// Show a chronological timeline of events matching a query
#[command(name = "timeline")]
Timeline(TimelineArgs),
```

### 3. Handler in `src/main.rs`:

```rust
Commands::Timeline(args) => {
    // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)
    // For now: resolve project, call timeline query, dispatch to renderer
}
```

### 4. VALID_COMMANDS: add `"timeline"` to the array

### 5. robot-docs: add timeline command description to manifest

## Acceptance Criteria

- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit
- [ ] Commands::Timeline variant registered in Commands enum
- [ ] Handler stub in src/main.rs dispatches to timeline logic
- [ ] `"timeline"` added to VALID_COMMANDS array
- [ ] robot-docs manifest includes timeline command description
- [ ] `lore timeline --help` shows correct help text
- [ ] `lore timeline` without query shows error (query is required positional)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)
- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)

## TDD Loop

No unit tests for CLI wiring. Verify with:

```bash
cargo check --all-targets
cargo run -- timeline --help
```

## Edge Cases

- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles "7d", "2w", "YYYY-MM-DD")
- --depth 0: valid, means no cross-reference expansion
- --expand-mentions: off by default because mentioned edges have high fan-out

Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow


[CP1] GitLab types for issues, discussions, notes

## Background

GitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.

## Approach

Add types to `src/gitlab/types.rs` with serde derives:

### GitLabIssue

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssue {
    pub id: i64,                      // GitLab global ID
    pub iid: i64,                     // Project-scoped issue number
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "closed"
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub labels: Vec<String>,          // Array of label names (CP1 canonical)
    pub web_url: String,
}
```

NOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.

### GitLabAuthor

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAuthor {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

### GitLabDiscussion

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiscussion {
    pub id: String,                   // String ID like "6a9c1750b37d..."
    pub individual_note: bool,        // true = standalone comment
    pub notes: Vec<GitLabNote>,
}
```

### GitLabNote

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNote {
    pub id: i64,
    #[serde(rename = "type")]
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub body: String,
    pub author: GitLabAuthor,
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub system: bool,                 // true for system-generated notes
    #[serde(default)]
    pub resolvable: bool,
    #[serde(default)]
    pub resolved: bool,
    pub resolved_by: Option<GitLabAuthor>,
    pub resolved_at: Option<String>,
    pub position: Option<GitLabNotePosition>,
}
```

### GitLabNotePosition

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNotePosition {
    pub old_path: Option<String>,
    pub new_path: Option<String>,
    pub old_line: Option<i32>,
    pub new_line: Option<i32>,
}
```

## Acceptance Criteria

- [ ] GitLabIssue deserializes from API response JSON
- [ ] GitLabAuthor embedded correctly in issue and note
- [ ] GitLabDiscussion with notes array deserializes
- [ ] GitLabNote handles null note_type (use Option<String>)
- [ ] GitLabNote uses #[serde(rename = "type")] for reserved keyword
- [ ] resolvable/resolved default to false via #[serde(default)]
- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)

## Files

- src/gitlab/types.rs (edit - add types)

## TDD Loop

RED:
```rust
// tests/gitlab_types_tests.rs
#[test] fn deserializes_gitlab_issue_from_json()
#[test] fn deserializes_gitlab_discussion_from_json()
#[test] fn handles_null_note_type()
#[test] fn handles_missing_resolvable_field()
#[test] fn deserializes_labels_as_string_array()
```

GREEN: Add type definitions with serde attributes

VERIFY: `cargo test gitlab_types`

## Edge Cases

- note_type can be null, "DiscussionNote", or "DiffNote"
- labels array can be empty
- description can be null
- resolved_by/resolved_at can be null
- position is only present for DiffNotes

Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests


Epic: Surgical Per-IID Sync

All child beads completed: bd-wcja (SyncResult fields), bd-1elx (SurgicalTarget types), bd-hs6j (TOCTOU guard), bd-3sez (preflight_fetch), bd-arka (ingest_issue_by_iid), bd-tiux (ingest_mr_by_iid), bd-1lja (SyncOptions extensions), bd-159p (dirty_tracker), bd-1sc6 (SyncRunRecorder), bd-kanh (enrichment helpers), bd-1i4i (orchestrator), bd-3bec (dispatch wiring). 886 tests pass.


OBSERV: Add -v/--verbose and --log-format CLI flags

## Background
Users and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.

## Approach
Add two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):

```rust
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
```

The existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.

Note: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).

## Acceptance Criteria
- [ ] lore -v sync parses without error (verbose=1)
- [ ] lore -vv sync parses (verbose=2)
- [ ] lore -vvv sync parses (verbose=3)
- [ ] lore --log-format json sync parses (log_format="json")
- [ ] lore --log-format text sync parses (default)
- [ ] lore --log-format xml sync errors (invalid value)
- [ ] Existing commands unaffected (verbose defaults to 0, log_format to "text")
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/mod.rs (modify Cli struct, lines 13-42)

## TDD Loop
RED: Write test that parses Cli with -v flag and asserts verbose=1
GREEN: Add the two fields to Cli struct
VERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings

## Edge Cases
- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here
- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v
- --log-format is a string, not enum, to keep Cli struct simple

Added -v/--verbose (count) and --log-format (text|json) global CLI flags


OBSERV: Define StageTiming struct in src/core/metrics.rs

## Background
StageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.

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

```rust
use serde::Serialize;

fn is_zero(v: &usize) -> bool { *v == 0 }

#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub elapsed_ms: u64,
    pub items_processed: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub items_skipped: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub errors: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sub_stages: Vec<StageTiming>,
}
```

Register module in src/core/mod.rs (line ~11, add):
```rust
pub mod metrics;
```

The is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.

## Acceptance Criteria
- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example
- [ ] items_skipped omitted when 0
- [ ] errors omitted when 0
- [ ] sub_stages omitted when empty vec
- [ ] project omitted when None
- [ ] name, elapsed_ms, items_processed always present
- [ ] Struct is Debug + Clone + Serialize
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (new file)
- src/core/mod.rs (register module, add line after existing pub mod declarations)

## TDD Loop
RED:
  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure
  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no "errors" or "items_skipped" keys
  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no "sub_stages" key
GREEN: Create metrics.rs with StageTiming struct and is_zero helper
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)
- Vec::is_empty is a method on Vec, and serde accepts "Vec::is_empty" as a path for skip_serializing_if
- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed

Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests


NOTE-2A: Schema migration for note documents (migration 024)

## Background
The documents and dirty_sources tables have CHECK constraints limiting source_type to ('issue', 'merge_request', 'discussion'). Need to add 'note' as valid source_type. SQLite doesn't support ALTER CONSTRAINT, so use the table-rebuild pattern. Uses migration slot 024 (022 = query indexes, 023 = issue_detail_fields already exists).

## Approach
Create migrations/024_note_documents.sql:

1. Rebuild dirty_sources: CREATE dirty_sources_new with CHECK adding 'note', INSERT SELECT, DROP old, RENAME.
2. Rebuild documents (complex — must preserve FTS consistency):
   - Save junction table data (_doc_labels_backup, _doc_paths_backup)
   - Drop FTS triggers (documents_ai, documents_ad, documents_au — defined in migration 008_fts5.sql)
   - Drop junction tables (document_labels, document_paths — defined in migration 007_documents.sql)
   - Create documents_new with updated CHECK adding 'note'
   - INSERT INTO documents_new SELECT * FROM documents (preserves rowids for FTS)
   - Drop documents, rename new
   - Recreate all indexes (idx_documents_project_updated, idx_documents_author, idx_documents_source, idx_documents_content_hash — see migration 007_documents.sql for definitions)
   - Recreate junction tables + restore data from backups
   - Recreate FTS triggers (see migration 008_fts5.sql for trigger SQL)
   - INSERT INTO documents_fts(documents_fts) VALUES('rebuild')
3. Defense-in-depth triggers:
   - notes_ad_cleanup: AFTER DELETE ON notes WHEN old.is_system = 0 → delete doc + dirty_sources for source_type='note', source_id=old.id
   - notes_au_system_cleanup: AFTER UPDATE OF is_system ON notes WHEN NEW.is_system = 1 AND OLD.is_system = 0 → delete doc + dirty_sources
4. Drop temp backup tables

Register as ("024", include_str!("../../migrations/024_note_documents.sql")) in MIGRATIONS array in src/core/db.rs. Position AFTER the "023" entry.

## Files
- CREATE: migrations/024_note_documents.sql
- MODIFY: src/core/db.rs (add ("024", include_str!(...)) to MIGRATIONS array, after line 75)

## TDD Anchor
RED: test_migration_024_allows_note_source_type — INSERT with source_type='note' should succeed in both documents and dirty_sources.
GREEN: Implement the table rebuild migration.
VERIFY: cargo test migration_024 -- --nocapture
Tests: test_migration_024_preserves_existing_data, test_migration_024_fts_triggers_intact, test_migration_024_row_counts_preserved, test_migration_024_integrity_checks_pass, test_migration_024_fts_rebuild_consistent, test_migration_024_note_delete_trigger_cleans_document, test_migration_024_note_system_flip_trigger_cleans_document, test_migration_024_system_note_delete_trigger_does_not_fire

## Acceptance Criteria
- [ ] INSERT source_type='note' succeeds in documents and dirty_sources
- [ ] All existing data preserved through table rebuild (row counts match before/after)
- [ ] FTS triggers fire correctly after rebuild (insert a doc, verify FTS entry exists)
- [ ] documents_fts row count == documents row count after rebuild
- [ ] PRAGMA foreign_key_check returns no violations
- [ ] notes_ad_cleanup trigger fires on note deletion (deletes document + dirty_sources)
- [ ] notes_au_system_cleanup trigger fires when is_system flips 0→1
- [ ] System note deletion does NOT trigger notes_ad_cleanup (is_system = 1 guard)
- [ ] All 9 tests pass

## Edge Cases
- Rowid preservation: INSERT INTO documents_new SELECT * preserves id column = rowid for FTS consistency
- CRITICAL: Must save/restore junction table data (ON DELETE CASCADE on document_labels/document_paths would delete them when documents table is dropped)
- The FTS rebuild at end is a safety net for any rowid drift
- Empty database: migration is a no-op (all SELECTs return 0 rows, tables rebuilt with new CHECK)


Register migration 015 in db.rs and create migration 016 for mr_file_changes

## Background

This bead creates the `mr_file_changes` table that stores which files each MR touched, enabling Gate 4 (file-history) and Gate 5 (trace). It maps MRs to the file paths they modify.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.1 (Schema).

## Codebase Context — CRITICAL Migration Numbering

- **LATEST_SCHEMA_VERSION = 14** (MIGRATIONS array in db.rs includes 001-014)
- **Migration 015 exists on disk** (`migrations/015_commit_shas_and_closes_watermark.sql`) but is **NOT registered** in `src/core/db.rs` MIGRATIONS array
- `merge_commit_sha` and `squash_commit_sha` are already on merge_requests (added by 015 SQL) and already used in `src/ingestion/merge_requests.rs`
- `closes_issues_synced_for_updated_at` also added by 015 and used in orchestrator.rs
- **This bead must FIRST register migration 015 in db.rs**, then create migration 016 for mr_file_changes
- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- Schema version auto-computes: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32`

## Approach

### Step 1: Register existing migration 015 in db.rs

Add to MIGRATIONS array in `src/core/db.rs` (after the "014" entry):

```rust
(
    "015",
    include_str!("../../migrations/015_commit_shas_and_closes_watermark.sql"),
),
```

This makes LATEST_SCHEMA_VERSION = 15.

### Step 2: Create migration 016 for mr_file_changes

Create `migrations/016_mr_file_changes.sql`:

```sql
-- Migration 016: MR file changes table
-- Powers file-history and trace commands (Gates 4-5)

CREATE TABLE mr_file_changes (
    id INTEGER PRIMARY KEY,
    merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    old_path TEXT,
    new_path TEXT NOT NULL,
    change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'renamed', 'deleted')),
    UNIQUE(merge_request_id, new_path)
);

CREATE INDEX idx_mfc_project_path ON mr_file_changes(project_id, new_path);
CREATE INDEX idx_mfc_project_old_path ON mr_file_changes(project_id, old_path) WHERE old_path IS NOT NULL;
CREATE INDEX idx_mfc_mr ON mr_file_changes(merge_request_id);
CREATE INDEX idx_mfc_renamed ON mr_file_changes(project_id, change_type) WHERE change_type = 'renamed';

INSERT INTO schema_version (version, applied_at, description)
VALUES (16, strftime('%s', 'now') * 1000, 'MR file changes table');
```

### Step 3: Register migration 016 in db.rs

```rust
(
    "016",
    include_str!("../../migrations/016_mr_file_changes.sql"),
),
```

LATEST_SCHEMA_VERSION will auto-compute to 16.

## Acceptance Criteria

- [ ] Migration 015 registered in MIGRATIONS array in src/core/db.rs
- [ ] Migration file exists at `migrations/016_mr_file_changes.sql`
- [ ] `mr_file_changes` table has columns: id, merge_request_id, project_id, old_path, new_path, change_type
- [ ] UNIQUE constraint on (merge_request_id, new_path)
- [ ] CHECK constraint on change_type: added, modified, renamed, deleted
- [ ] 4 indexes: project+new_path, project+old_path (partial), mr_id, project+renamed (partial)
- [ ] Migration 016 registered in MIGRATIONS array
- [ ] LATEST_SCHEMA_VERSION auto-computes to 16
- [ ] `lore migrate` applies both 015 and 016 successfully on a v14 database
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/db.rs` (register migrations 015 AND 016 in MIGRATIONS array)
- `migrations/016_mr_file_changes.sql` (NEW)

## TDD Loop

RED: `lore migrate` on v14 database says "already up to date" (015 not registered)

GREEN: Register 015 in db.rs, create 016 file, register 016 in db.rs. `lore migrate` applies both.

VERIFY:
```bash
cargo check --all-targets
lore --robot migrate
sqlite3 ~/.local/share/lore/lore.db '.schema mr_file_changes'
sqlite3 ~/.local/share/lore/lore.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
```

## Edge Cases

- Databases already at v15 via manual migration: 015 will be skipped, only 016 applied
- old_path is NULL for added files, populated for renamed/deleted
- No lines_added/lines_removed columns (spec does not require them; removed to match spec exactly)
- Partial indexes only index relevant rows for rename chain BFS performance

Completed: registered migration 015 in db.rs MIGRATIONS array, created migration 016 (mr_file_changes table with 4 indexes, CHECK constraint, UNIQUE constraint), registered 016 in db.rs. LATEST_SCHEMA_VERSION auto-computes to 16. cargo check, clippy, and fmt all pass.


NOTE-1D: robot-docs integration for notes command

## Background
Add the notes command to the robot-docs manifest so agents can discover it. Also forward-prep SearchArgs --type to accept "note"/"notes" (duplicates work in NOTE-2F but is safe to do early).

## Approach
1. Robot-docs manifest is in src/main.rs, function handle_robot_docs() starting at line 2087. The commands JSON is built at line 2090 with serde_json::json!. Add a "notes" entry following the pattern of "issues" (line 2107 area) and "mrs" entries:

   "notes": {
       "description": "List notes from discussions with rich filtering",
       "flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
       "robot_flags": ["--format json", "--fields minimal"],
       "example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal",
       "response_schema": {
           "ok": "bool",
           "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"},
           "meta": {"elapsed_ms": "int"}
       }
   }

2. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560) to include "note":
   value_parser = ["issue", "mr", "discussion", "note"]
   (This is also done in NOTE-2F but is safe to do in either order — value_parser is additive)

3. Add "notes" to the command list in handle_robot_docs (line 662 area where command names are listed).

## Files
- MODIFY: src/main.rs (add notes to robot-docs commands JSON at line 2090 area, add to command list at line 662)
- MODIFY: src/cli/mod.rs (add "note" to SearchArgs source_type value_parser at line 560)

## TDD Anchor
Smoke test: cargo run -- --robot robot-docs | jq '.data.commands.notes' should return the notes command entry.
VERIFY: cargo test -- --nocapture (no dedicated test needed — robot-docs is a static JSON generator)

## Acceptance Criteria
- [ ] lore robot-docs output includes notes command with all flags
- [ ] notes command has response_schema, example, and robot_flags
- [ ] SearchArgs accepts --type note
- [ ] All existing tests still pass

## Dependency Context
- Depends on NOTE-1A (bd-20p9), NOTE-1B (bd-3iod), NOTE-1C (bd-25hb): command must be fully wired before documenting (the manifest should describe actual working behavior)

## Edge Cases
- robot-docs --brief mode: notes command should still appear in brief output
- Value parser order doesn't matter — "note" can be added at any position in the array


Implement responsive layout system (LORE_BREAKPOINTS + Responsive)

## Background

Every TUI view needs to adapt its layout to terminal width. The PRD defines a project-wide breakpoint system using FrankenTUI native `Responsive<T>` with 5 tiers: Xs (<60), Sm (60-89), Md (90-119), Lg (120-159), Xl (160+). This is cross-cutting infrastructure — Dashboard uses it for 1/2/3-column layout, Issue/MR lists use it for column visibility, Search/Who use it for split-pane toggling. Without this, every view would reinvent breakpoint logic.

## Approach

Define a single `layout.rs` module in the TUI crate with:

1. **`LORE_BREAKPOINTS` constant** — `Breakpoints::new(60, 90, 120)` (Xl defaults to Lg + 40 = 160)
2. **`classify_width(width: u16) -> Breakpoint`** — wrapper around `LORE_BREAKPOINTS.classify_width(area.width)`
3. **Helper functions** using `Responsive<T>`:
   - `dashboard_columns(bp: Breakpoint) -> u16` — 1 (Xs/Sm), 2 (Md), 3 (Lg/Xl)
   - `show_preview_pane(bp: Breakpoint) -> bool` — false (Xs/Sm), true (Md+)
   - `table_columns(bp: Breakpoint, screen: Screen) -> Vec<ColumnDef>` — returns visible column set per breakpoint per screen

Reference FrankenTUI types:
```rust
use ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint};
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);
```

The `Responsive<T>` wrapper provides breakpoint-aware values with inheritance — `.new(base)` sets Xs, `.at(bp, val)` sets overrides, `.resolve_cloned(bp)` walks downward.

## Acceptance Criteria
- [ ] `LORE_BREAKPOINTS` constant defined with sm=60, md=90, lg=120
- [ ] `classify_width()` returns correct Breakpoint for widths: 40->Xs, 60->Sm, 90->Md, 120->Lg, 160->Xl
- [ ] `dashboard_columns()` returns 1 for Xs/Sm, 2 for Md, 3 for Lg/Xl
- [ ] `show_preview_pane()` returns false for Xs/Sm, true for Md+
- [ ] All helpers use `Responsive<T>` (not bare match), so inheritance is automatic
- [ ] Module is `pub` and importable by all view modules

## Files
- CREATE: crates/lore-tui/src/layout.rs
- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod layout;`)

## TDD Anchor
RED: Write `test_classify_width_boundaries` that asserts classify_width(59)=Xs, classify_width(60)=Sm, classify_width(89)=Sm, classify_width(90)=Md, classify_width(119)=Md, classify_width(120)=Lg, classify_width(159)=Lg, classify_width(160)=Xl.
GREEN: Implement LORE_BREAKPOINTS and classify_width().
VERIFY: cargo test -p lore-tui classify_width

Additional tests:
- test_dashboard_columns_per_breakpoint
- test_show_preview_pane_per_breakpoint
- test_responsive_inheritance (Sm inherits from Xs when no Sm override set)

## Edge Cases
- Terminal width of 0 or 1 must not panic — classify to Xs
- Very wide terminals (>300 cols) should still work — classify to Xl
- All Responsive<T> values must have an Xs base so resolve never fails

## Dependency Context
- Depends on bd-3ddw (crate scaffold) which creates the crates/lore-tui/ workspace
- Consumed by all view beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-2kr0 MR List, bd-1zow Search, bd-u7se Who, etc.) for layout decisions

Implemented responsive layout system with LORE_BREAKPOINTS, classify_width, dashboard_columns, show_preview_pane. All backed by ftui::layout::Breakpoint/Breakpoints. 5 tests cover boundaries, per-breakpoint behavior, and edge cases. Build blocked by upstream lore crate errors (other agents WIP), not by layout.rs.


WHO: Epic — People Intelligence Commands

## Background

The current beads roadmap focuses on Gate 4/5 (file-history, code-trace) — archaeology queries requiring mr_file_changes data that does not exist yet. Meanwhile, the DB has rich people/activity data (280K notes, 210K discussions, 33K DiffNotes with file positions, 53 active participants) that can answer collaboration questions immediately with zero new tables or API calls.

## Scope

This epic builds `lore who` — a pure SQL query layer answering 5 questions:
1. **Expert**: "Who should I talk to about this feature/file?" (DiffNote path analysis)
2. **Workload**: "What is person X working on?" (open issues, authored/reviewing MRs, unresolved discussions)
3. **Reviews**: "What review patterns does person X have?" (DiffNote **prefix** category extraction)
4. **Active**: "What discussions are actively in progress?" (unresolved resolvable discussions)
5. **Overlap**: "Who else has MRs/notes touching my files?" (path-based activity overlap)

## Plan Reference

Full implementation plan with 8 iterations of review: `docs/who-command-design.md`

## Children (Execution Order)

1. **bd-34rr** — Migration 017: 5 composite indexes for query performance
2. **bd-2rk9** — CLI skeleton: WhoArgs, Commands::Who, dispatch, stub file
3. **bd-2ldg** — Mode resolution, path helpers, run_who entry point, all result types
4. **bd-zqpf** — Expert mode query (CTE + MR-breadth scoring)
5. **bd-s3rc** — Workload mode query (4 SELECT queries)
6. **bd-m7k1** — Active mode query (CTE + global/scoped SQL variants)
7. **bd-b51e** — Overlap mode query (dual role tracking + accumulator)
8. **bd-2711** — Reviews mode query (prefix extraction + normalization)
9. **bd-1rdi** — Human terminal output for all 5 modes
10. **bd-3mj2** — Robot JSON output for all 5 modes
11. **bd-tfh3** — Comprehensive test suite (20+ tests)
12. **bd-zibc** — VALID_COMMANDS + robot-docs manifest
13. **bd-g0d5** — Verification gate (check, clippy, fmt, EXPLAIN QUERY PLAN)

## Design Principles (from plan)

- All SQL fully static — no format!() for query text, LIMIT bound as ?N
- prepare_cached() everywhere for statement caching
- (?N IS NULL OR ...) nullable binding except Active mode (two SQL variants for index selection)
- Self-review exclusion on all DiffNote-based branches
- Deterministic output: sorted GROUP_CONCAT, sorted HashSet-derived vectors, stable tie-breakers
- Truncation transparency: LIMIT+1 pattern with truncated bool
- Bounded payloads: capped arrays with *_total + *_truncated metadata
- Robot-first reproducibility: input + resolved_input with since_mode tri-state

## Files

| File | Action | Description |
|---|---|---|
| `src/cli/commands/who.rs` | CREATE | All 5 query modes + human/robot output |
| `src/cli/commands/mod.rs` | MODIFY | Add `pub mod who` + re-exports |
| `src/cli/mod.rs` | MODIFY | Add `WhoArgs` struct + `Commands::Who` variant |
| `src/main.rs` | MODIFY | Add dispatch arm + `handle_who` fn + VALID_COMMANDS + robot-docs |
| `src/core/db.rs` | MODIFY | Add migration 017: composite indexes for who query paths |

## TDD Loop

Each child bead has its own RED/GREEN/VERIFY cycle. The epic TDD strategy:
- RED: Tests in bd-tfh3 (written alongside query beads)
- GREEN: Query implementations in bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711
- VERIFY: bd-g0d5 runs `cargo test` + `cargo clippy` + EXPLAIN QUERY PLAN

## Acceptance Criteria

- [ ] `lore who src/path/` shows ranked experts with scores
- [ ] `lore who @username` shows workload across all projects
- [ ] `lore who @username --reviews` shows categorized review patterns
- [ ] `lore who --active` shows unresolved discussions
- [ ] `lore who --overlap src/path/` shows other contributors
- [ ] `lore who --path README.md` handles root files
- [ ] `lore -J who ...` produces valid JSON with input + resolved_input
- [ ] All indexes verified via EXPLAIN QUERY PLAN
- [ ] cargo check + clippy + fmt + test all pass

## Edge Cases

- This epic has zero new tables — all queries are pure SQL over existing schema + migration 017 indexes
- Gate 4/5 beads are NOT dependencies — who command works independently with current data
- If DB has <1000 notes, queries will work but results will be sparse — this is expected for fresh installations
- format_relative_time() is duplicated from list.rs intentionally (private fn, small blast radius > refactoring shared module)
- lookup_project_path() is local to who.rs — single invocation per run, does not warrant shared utility

All 13 child beads implemented: migration 017 (5 composite indexes), CLI skeleton with WhoArgs/dispatch/robot-docs, 5 query modes (expert/workload/active/overlap/reviews), human terminal + robot JSON output, 20 tests. All quality gates pass: cargo check, clippy (pedantic+nursery), fmt, test.


[CP1] Discussion and note transformers

## Background

Discussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.

## Approach

Create transformer module with:

### Structs

```rust
// src/gitlab/transformers/discussion.rs

pub struct NormalizedDiscussion {
    pub gitlab_discussion_id: String,
    pub project_id: i64,
    pub issue_id: i64,
    pub noteable_type: String,        // "Issue"
    pub individual_note: bool,
    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch
    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch
    pub last_seen_at: i64,
    pub resolvable: bool,             // any note is resolvable
    pub resolved: bool,               // all resolvable notes are resolved
}

pub struct NormalizedNote {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub is_system: bool,              // from note.system
    pub author_username: String,
    pub body: String,
    pub created_at: i64,              // ms epoch
    pub updated_at: i64,              // ms epoch
    pub last_seen_at: i64,
    pub position: i32,                // 0-indexed array position
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<i64>,
}
```

### Functions

```rust
pub fn transform_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_issue_id: i64,
) -> NormalizedDiscussion

pub fn transform_notes(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Vec<NormalizedNote>
```

## Acceptance Criteria

- [ ] `NormalizedDiscussion` struct with all fields
- [ ] `NormalizedNote` struct with all fields
- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array
- [ ] `transform_discussion` computes resolvable (any note is resolvable)
- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)
- [ ] `transform_notes` preserves array order via position field (0-indexed)
- [ ] `transform_notes` maps system flag to is_system
- [ ] Unit tests cover all computed fields

## Files

- src/gitlab/transformers/mod.rs (add `pub mod discussion;`)
- src/gitlab/transformers/discussion.rs (create)

## TDD Loop

RED:
```rust
// tests/discussion_transformer_tests.rs
#[test] fn transforms_discussion_payload_to_normalized_schema()
#[test] fn extracts_notes_array_from_discussion()
#[test] fn sets_individual_note_flag_correctly()
#[test] fn flags_system_notes_with_is_system_true()
#[test] fn preserves_note_order_via_position_field()
#[test] fn computes_first_note_at_and_last_note_at_correctly()
#[test] fn computes_resolvable_and_resolved_status()
```

GREEN: Implement transform_discussion and transform_notes

VERIFY: `cargo test discussion_transformer`

## Edge Cases

- Discussion with single note - first_note_at == last_note_at
- All notes are system notes - still compute timestamps
- No notes resolvable - resolvable=false, resolved=false
- Mix of resolved/unresolved notes - resolved=false until all done

Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests


Implement NavigationStack (back/forward/jump list)

## Background
Navigation uses a stack with global shortcuts, supporting back/forward (browser-like) and jump list (vim-like Ctrl+O/Ctrl+I). State is preserved when navigating away — screens are never cleared on pop. The jump list only records "significant" hops (detail views, cross-references).

## Approach
Create crates/lore-tui/src/navigation.rs:
- NavigationStack struct: back_stack (Vec<Screen>), current (Screen), forward_stack (Vec<Screen>), jump_list (Vec<Screen>), jump_index (usize), browse_snapshots (HashMap<ScreenKind, BrowseSnapshot>)
- new() -> Self: initializes with Dashboard as current
- current() -> &Screen
- is_at(&Screen) -> bool
- push(Screen): pushes current to back_stack, clears forward_stack, sets new current, records detail hops in jump_list
- pop() -> Option<Screen>: pops from back_stack, pushes current to forward_stack
- go_forward() -> Option<Screen>: pops from forward_stack, pushes current to back_stack
- jump_back() -> Option<&Screen>: moves backward in jump list (Ctrl+O)
- jump_forward() -> Option<&Screen>: moves forward in jump list (Ctrl+I)
- reset_to(Screen): clears all stacks, sets new current (H=Home)
- breadcrumbs() -> Vec<&str>: returns labels for breadcrumb display
- depth() -> usize: back_stack.len() + 1
- BrowseSnapshot struct: per-screen pagination cursor snapshot for stable ordering during concurrent writes

## Acceptance Criteria
- [ ] push() adds to back_stack and clears forward_stack
- [ ] pop() moves current to forward_stack and restores previous
- [ ] go_forward() restores from forward_stack
- [ ] jump_back/forward navigates only through detail views
- [ ] reset_to() clears all history
- [ ] breadcrumbs() returns ordered screen labels
- [ ] pop() returns None at root (can't pop past Dashboard)
- [ ] push() only records is_detail_or_entity() screens in jump_list

## Files
- CREATE: crates/lore-tui/src/navigation.rs

## TDD Anchor
RED: Write test_push_pop_preserves_order that pushes Dashboard->IssueList->IssueDetail, pops twice, verifies correct order.
GREEN: Implement push/pop with back_stack.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_push_pop

Additional tests:
- test_forward_stack_cleared_on_new_push
- test_jump_list_skips_list_screens
- test_reset_clears_all_history
- test_pop_at_root_returns_none
- test_breadcrumbs_reflect_stack

## Edge Cases
- Stack depth has no explicit limit — deeply nested cross-reference chains are supported
- Forward stack must be cleared on any new push (browser behavior)
- Jump list must truncate forward entries when recording a new jump (vim behavior)

## Dependency Context
Uses Screen enum and Screen::is_detail_or_entity() from "Implement core types" task.

NavigationStack: push/pop/forward, vim jump list, breadcrumbs, reset. 13 tests, quality gate green.


[CP1] Database migration 002_issues.sql

Create migration file with tables for issues, labels, issue_labels, discussions, and notes.

## Tables

### issues
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER UNIQUE NOT NULL
- project_id INTEGER NOT NULL REFERENCES projects(id)
- iid INTEGER NOT NULL
- title TEXT, description TEXT, state TEXT
- author_username TEXT
- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)
- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)
- web_url TEXT
- raw_payload_id INTEGER REFERENCES raw_payloads(id)

### labels (name-only for CP1)
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER (optional, for future Labels API)
- project_id INTEGER NOT NULL REFERENCES projects(id)
- name TEXT NOT NULL
- color TEXT, description TEXT (nullable, deferred)
- UNIQUE(project_id, name)

### issue_labels (junction)
- issue_id, label_id with CASCADE DELETE
- Clear existing links before INSERT to handle removed labels

### discussions
- gitlab_discussion_id TEXT (string ID from API)
- project_id, issue_id/merge_request_id FKs
- noteable_type TEXT ('Issue' | 'MergeRequest')
- individual_note INTEGER, first_note_at, last_note_at, last_seen_at
- resolvable, resolved flags
- CHECK constraint for Issue vs MR exclusivity

### notes
- gitlab_id INTEGER UNIQUE NOT NULL
- discussion_id, project_id FKs
- note_type, is_system, author_username, body
- timestamps, position (array order)
- resolution fields, DiffNote position fields

## Indexes
- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync
- uq_issues_project_iid, uq_labels_project_name
- idx_issue_labels_label
- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note
- idx_notes_discussion/author/system

Files: migrations/002_issues.sql
Done when: Migration applies cleanly on top of 001_initial.sql, schema_version = 2


WHO: Human terminal output for all 5 modes

## Background

Terminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.

## Approach

### Dispatch:
```rust
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
    match result {
        WhoResult::Expert(r) => print_expert_human(r, project_path),
        WhoResult::Workload(r) => print_workload_human(r),
        WhoResult::Reviews(r) => print_reviews_human(r),
        WhoResult::Active(r) => print_active_human(r, project_path),
        WhoResult::Overlap(r) => print_overlap_human(r, project_path),
    }
}
```

### Shared helpers:
- **print_scope_hint()**: dim "(aggregated across all projects; use -p to scope)" when project_path is None. Called by Expert, Active, Overlap.
- **format_relative_time(ms_epoch)**: "just now" / "N min ago" / "N hours ago" / "N days ago" / "N weeks ago" / "N months ago" — DUPLICATE from list.rs (private there, keep blast radius small)
- **truncate_str(s, max)**: Unicode-aware, appends "..." if truncated

### Mode formats:
- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. "-" for zero counts.
- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.
- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.
- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.
- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.

### All modes: truncation dim hints, empty-state messages, console::style formatting.

## Files

- `src/cli/commands/who.rs`

## TDD Loop

No unit tests for print functions (they write to stdout). Verification is manual smoke test.
VERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`

## Acceptance Criteria

- [ ] cargo check passes (all print functions compile)
- [ ] Each mode produces readable, aligned terminal output
- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)
- [ ] Truncation hints shown when results exceed limit
- [ ] Empty-state messages for zero results

## Edge Cases

- format_relative_time handles negative diff ("in the future")
- truncate_str is Unicode-aware (.chars().count(), not .len())
- Workload shows empty message only when ALL 4 sections are empty

Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.


[CP1] gi show issue command

Show issue details with discussions.

Flags:
- --project=PATH (required if iid is ambiguous across projects)

Output:
- Title, project, state, author, dates, labels, URL
- Description text
- All discussions with notes (formatted thread view)

Handle ambiguity: If multiple projects have same iid, prompt for --project or show error.

Files: src/cli/commands/show.ts
Done when: Issue detail view displays all fields including threaded discussions


[CP1] Integration tests for issue ingestion

Full integration tests for issue ingestion module.

## Tests (tests/issue_ingestion_tests.rs)

- inserts_issues_into_database
- creates_labels_from_issue_payloads
- links_issues_to_labels_via_junction_table
- removes_stale_label_links_on_resync
- stores_raw_payload_for_each_issue
- stores_raw_payload_for_each_discussion
- updates_cursor_incrementally_per_page
- resumes_from_cursor_on_subsequent_runs
- handles_issues_with_no_labels
- upserts_existing_issues_on_refetch
- skips_discussion_refetch_for_unchanged_issues

## Test Setup
- tempfile::TempDir for isolated database
- wiremock::MockServer for GitLab API
- Mock handlers returning fixture data

Files: tests/issue_ingestion_tests.rs
Done when: All integration tests pass with mocked GitLab


Add SurgicalPreflightFailed error variant and foundation visibility changes

## Background
Surgical sync needs a dedicated error variant for preflight failures (e.g., IID not found on GitLab, project mismatch). The existing `GitLabNotFound` variant maps to exit code 6 and is too generic — it does not convey that the failure occurred during surgical preflight validation. A new `SurgicalPreflightFailed` variant in `LoreError` with a clear Display message and exit code 6 provides actionable feedback to both human and robot consumers.

Additionally, the `process_single_issue` function in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` are currently private. The surgical sync orchestrator (downstream bead bd-3sez) will need to call these from `src/core/surgical.rs`, so they must be raised to `pub(crate)` visibility. No config field is needed for this bead — the surgical sync feature is triggered purely by CLI flags (bead bd-1lja).

## Approach

### Step 1: Add ErrorCode variant (src/core/error.rs, line ~23)
Add `SurgicalPreflightFailed` to the `ErrorCode` enum (after `Ambiguous`). Wire it through three impls:
- `Display`: maps to `"SURGICAL_PREFLIGHT_FAILED"`
- `exit_code()`: maps to `6` (same category as GitLabNotFound — resource-level failure)

### Step 2: Add LoreError variant (src/core/error.rs, after EmbeddingsNotBuilt ~line 155)
```rust
#[error("Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}")]
SurgicalPreflightFailed {
    entity_type: String,  // "issue" or "merge_request"
    iid: u64,
    project: String,
    reason: String,
},
```
Wire in `code()` → `ErrorCode::SurgicalPreflightFailed`, `suggestion()` → a helpful message about verifying the IID exists, `actions()` → `["lore issues -p <project>", "lore mrs -p <project>"]`.

### Step 3: Raise visibility (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)
- `process_single_issue` at line 143: `fn` → `pub(crate) fn`
- `process_single_mr` at line 144: `fn` → `pub(crate) fn`
- `ProcessMrResult` at line 138: `struct` → `pub(crate) struct` (and its fields)

## Acceptance Criteria
- [ ] `ErrorCode::SurgicalPreflightFailed` exists with Display `"SURGICAL_PREFLIGHT_FAILED"` and exit code 6
- [ ] `LoreError::SurgicalPreflightFailed { entity_type, iid, project, reason }` exists
- [ ] `LoreError::SurgicalPreflightFailed { .. }.code()` returns `ErrorCode::SurgicalPreflightFailed`
- [ ] Display output includes entity_type, iid, project, and reason
- [ ] `suggestion()` returns a non-None helpful string
- [ ] `process_single_issue` is `pub(crate)`
- [ ] `process_single_mr` is `pub(crate)`
- [ ] `ProcessMrResult` and its fields are `pub(crate)`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] All existing tests pass

## Files
- MODIFY: src/core/error.rs (add ErrorCode variant, LoreError variant, wire Display/exit_code/code/suggestion/actions)
- MODIFY: src/ingestion/issues.rs (pub(crate) on process_single_issue)
- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on process_single_mr, ProcessMrResult + fields)

## TDD Anchor
RED: Write three tests in a new `#[cfg(test)] mod tests` block at the bottom of `src/core/error.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn surgical_preflight_failed_display() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "issue".to_string(),
            iid: 42,
            project: "group/repo".to_string(),
            reason: "not found on GitLab".to_string(),
        };
        let msg = err.to_string();
        assert!(msg.contains("issue"), "missing entity_type: {msg}");
        assert!(msg.contains("42"), "missing iid: {msg}");
        assert!(msg.contains("group/repo"), "missing project: {msg}");
        assert!(msg.contains("not found on GitLab"), "missing reason: {msg}");
    }

    #[test]
    fn surgical_preflight_failed_error_code() {
        let code = ErrorCode::SurgicalPreflightFailed;
        assert_eq!(code.exit_code(), 6);
    }

    #[test]
    fn surgical_preflight_failed_code_mapping() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "merge_request".to_string(),
            iid: 99,
            project: "ns/proj".to_string(),
            reason: "404".to_string(),
        };
        assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);
    }
}
```

GREEN: Add the variant and wire all impls.
VERIFY: `cargo test surgical_preflight_failed`

## Edge Cases
- Exit code 6 is shared with `GitLabNotFound` — this is intentional (same semantic category: resource not found). The `ErrorCode` Display string distinguishes them for robot consumers.
- The `entity_type` field uses strings ("issue", "merge_request") rather than an enum to avoid over-abstraction for two values.
- Visibility changes are `pub(crate)`, not `pub` — these are internal implementation details, not public API.

## Dependency Context
This is a leaf/foundation bead with no upstream dependencies. Downstream beads bd-1i4i (orchestrator) and bd-3sez (surgical.rs module) depend on this for both the error variant and the pub(crate) visibility of ingestion functions.


Epic: Gate 2 - Cross-Reference Extraction

## Background
Gate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, "lore timeline auth migration" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words "auth migration."

Three data sources feed entity_references:
1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues
2. **State events (reliable):** resource_state_events.source_merge_request_id
3. **System note parsing (best-effort):** "mentioned in !456", "closed by !789" patterns

## Architecture
- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.
- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.
- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.
- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.
- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.

## Children (Execution Order)
1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)
2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)
3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references
4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns

## Gate Completion Criteria
- [ ] entity_references populated from closes_issues API for all synced MRs
- [ ] entity_references populated from state events where source_merge_request_id present
- [ ] System notes parsed for cross-reference patterns (English instances)
- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)
- [ ] source_method tracks provenance of each reference
- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)
- [ ] Timeline JSON includes expansion provenance (via) for expanded entities
- [ ] Integration test: sync with all three extraction methods, verify entity_references populated

## Dependencies
- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue
- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion

All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)


Implement scope context (global project filter)

## Background
The scope context provides a global project filter that flows through all query bridge functions. Users can pin to a specific project set or view all projects. The P keybinding opens a scope picker overlay. Scope is persisted in session state.

## Approach
Create crates/lore-tui/src/scope.rs:
- ScopeContext enum: AllProjects, Pinned(Vec<ProjectInfo>)
- ProjectInfo: id (i64), path (String)
- scope_filter_sql(scope: &ScopeContext) -> String: generates WHERE clause fragment
- All action.rs query functions accept &ScopeContext parameter
- Scope picker overlay: list of projects with checkbox selection
- P keybinding toggles scope picker from any screen

## Acceptance Criteria
- [ ] AllProjects scope returns unfiltered results
- [ ] Pinned scope filters to specific project IDs
- [ ] All query functions respect global scope
- [ ] P keybinding opens scope picker
- [ ] Scope persisted in session state
- [ ] Scope change triggers re-query of current screen

## Files
- CREATE: crates/lore-tui/src/scope.rs
- MODIFY: crates/lore-tui/src/action.rs (add scope parameter to all queries)

## TDD Anchor
RED: Write test_scope_filter_sql that creates Pinned scope with 2 projects, asserts generated SQL contains IN (1, 2).
GREEN: Implement scope_filter_sql.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_scope_filter

## Edge Cases
- Single-project datasets: scope picker not needed, but should still work
- Very many projects (>50): scope picker should be scrollable
- Scope change mid-pagination: reset cursor to first page

## Dependency Context
Uses AppState from "Implement AppState composition" task.
Uses session persistence from "Implement session persistence" task.

Implemented scope context: scope.rs (SQL helper + project fetcher), state/scope_picker.rs, view/scope_picker.rs. P keybinding + toggle_scope. 629 tests pass.


Add half_life_decay() pure function

## Background
The decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.

## Approach
Add to who.rs as a private function near the top of the module (before query_expert):

```rust
/// Exponential half-life decay: R = 2^(-t/h)
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
    let hl = f64::from(half_life_days);
    if hl <= 0.0 { return 0.0; }
    2.0_f64.powf(-days / hl)
}
```

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_half_life_decay_math() {
    let hl_180 = 180;
    // At t=0, full retention
    assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
    // At t=half_life, exactly 0.5
    let one_hl_ms = 180 * 86_400_000_i64;
    assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
    // At t=2*half_life, exactly 0.25
    assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
    // Negative elapsed clamped to 0 -> 1.0
    assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
    // Zero half-life -> 0.0 (div-by-zero guard)
    assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
}

#[test]
fn test_score_monotonicity_by_age() {
    // For any half-life, older timestamps must never produce higher decay than newer ones.
    // Use deterministic LCG PRNG (no rand dependency).
    let mut seed: u64 = 42;
    let hl = 90_u32;
    for _ in 0..50 {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let newer_ms = (seed % 100_000_000) as i64;       // 0-100M ms (~1.15 days max)
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer
        assert!(
            half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
            "Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
        );
    }
}
```

### GREEN: Add the half_life_decay function (3 lines of math).
### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`

## Acceptance Criteria
- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)
- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)
- [ ] Function is `fn` not `pub fn` (module-private)
- [ ] No DB dependency — pure function

## Files
- src/cli/commands/who.rs (function near top, tests in test module)

## Edge Cases
- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0
- half_life_days = 0: returns 0.0, not NaN/Inf
- Very large elapsed (10 years): returns very small positive f64, never negative

Completed: added half_life_decay(elapsed_ms, half_life_days) -> f64 pure function with div-by-zero guard, negative elapsed clamping, and 2 tests (boundary math + monotonicity property). All 585 tests pass.


Epic: CP2 Gate C - Dependent Discussion Sync

## Background
Gate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.

## Acceptance Criteria (Pass/Fail)
- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at
- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0
- [ ] DiffNotes have `position_new_path` populated (file path)
- [ ] DiffNotes have `position_new_line` populated (line number)
- [ ] DiffNotes have `position_type` populated (text/image/file)
- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`
- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)
- [ ] Watermark NOT advanced on HTTP error mid-pagination
- [ ] Watermark NOT advanced on note timestamp parse failure
- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`

## Validation Script
```bash
#!/bin/bash
set -e

DB_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3"

echo "=== Gate C: Dependent Discussion Sync ==="

# 1. Check discussion count for MRs
echo "Step 1: Check MR discussion count..."
MR_DISC_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;")
echo "  MR discussions: $MR_DISC_COUNT"
[ "$MR_DISC_COUNT" -gt 0 ] || { echo "FAIL: No MR discussions found"; exit 1; }

# 2. Check note count
echo "Step 2: Check note count..."
NOTE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id IS NOT NULL;
")
echo "  MR notes: $NOTE_COUNT"

# 3. Check DiffNote position data
echo "Step 3: Check DiffNote positions..."
DIFFNOTE_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;")
echo "  DiffNotes with position: $DIFFNOTE_COUNT"

# 4. Sample DiffNote data
echo "Step 4: Sample DiffNote data..."
sqlite3 "$DB_PATH" "
  SELECT
    n.gitlab_id,
    n.position_new_path,
    n.position_new_line,
    n.position_type,
    SUBSTR(n.position_head_sha, 1, 7) as head_sha
  FROM notes n
  WHERE n.position_new_path IS NOT NULL
  LIMIT 5;
"

# 5. Check multi-line DiffNotes
echo "Step 5: Check multi-line DiffNotes..."
MULTILINE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes
  WHERE position_line_range_start IS NOT NULL
    AND position_line_range_end IS NOT NULL
    AND position_line_range_start != position_line_range_end;
")
echo "  Multi-line DiffNotes: $MULTILINE_COUNT"

# 6. Check watermarks set
echo "Step 6: Check watermarks..."
WATERMARKED=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  MRs with watermark set: $WATERMARKED"

# 7. Check last_seen_at for sweep pattern
echo "Step 7: Check last_seen_at (sweep pattern)..."
sqlite3 "$DB_PATH" "
  SELECT
    MIN(last_seen_at) as oldest,
    MAX(last_seen_at) as newest
  FROM discussions
  WHERE merge_request_id IS NOT NULL;
"

# 8. Test show command with DiffNote
echo "Step 8: Find MR with DiffNotes for show test..."
MR_IID=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$MR_IID" ]; then
  echo "  Testing: gi show mr $MR_IID"
  gi show mr "$MR_IID" | head -50
fi

# 9. Re-run and verify skip count
echo "Step 9: Re-run ingest (should skip unchanged MRs)..."
gi ingest --type=merge_requests
# Should report "Skipped discussion sync for N unchanged MRs"

echo ""
echo "=== Gate C: PASSED ==="
```

## Atomicity Test (Manual - Kill Test)
```bash
# This tests that partial failure preserves data

# 1. Get an MR with discussions
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT m.id FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  LIMIT 1;
")

# 2. Note current note count
BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes before: $BEFORE"

# 3. Note watermark
WATERMARK_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark before: $WATERMARK_BEFORE"

# 4. Force full sync and kill mid-run
gi ingest --type=merge_requests --full &
PID=$!
sleep 3 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# 5. Verify notes preserved (should be same or more, never less)
AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes after kill: $AFTER"
[ "$AFTER" -ge "$BEFORE" ] || echo "WARNING: Notes decreased - atomicity may be broken"

# 6. Note watermark should NOT have advanced if killed mid-pagination
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after: $WATERMARK_AFTER"
```

## Test Commands (Quick Verification)
```bash
# Check DiffNote data:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,
    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,
    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;
"

# Find MR with DiffNotes and show it:
gi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT DISTINCT m.iid FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL LIMIT 1;
")
```

## Dependencies
This gate requires:
- bd-3j6 (Discussion transformer with DiffNote position extraction)
- bd-20h (MR discussion ingestion with atomicity guarantees)
- bd-iba (Client pagination for MR discussions)
- Gates A and B must pass first

## Edge Cases
- MRs without discussions: should sync successfully, just with 0 discussions
- Discussions without DiffNotes: regular comments have NULL position fields
- Deleted discussions in GitLab: sweep pattern should remove them locally
- Invalid note timestamps: should NOT advance watermark, should log warning


[CP1] Integration tests for pagination

Integration tests for GitLab pagination with wiremock.

## Tests (tests/pagination_tests.rs)

### Page Navigation
- fetches_all_pages_when_multiple_exist
- respects_per_page_parameter
- follows_x_next_page_header_until_empty
- falls_back_to_empty_page_stop_if_headers_missing

### Cursor Behavior
- applies_cursor_rewind_for_tuple_semantics
- clamps_negative_rewind_to_zero

## Test Setup
- Use wiremock::MockServer
- Set up handlers for /api/v4/projects/:id/issues
- Return x-next-page headers
- Verify request params (updated_after, per_page)

Files: tests/pagination_tests.rs
Done when: All pagination tests pass with mocked server