Implement document regenerator

## Background
The document regenerator drains the dirty_sources queue, regenerating documents for each entry. It uses per-item transactions for crash safety, a triple-hash fast path to skip unchanged documents entirely (no writes at all), and a bounded batch loop that drains completely. Error recording includes backoff computation.

## Approach
Create `src/documents/regenerator.rs` per PRD Section 6.3.

**Core function:**
```rust
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult>
```

**RegenerateResult:** { regenerated, unchanged, errored }

**Algorithm (per PRD):**
1. Loop: get_dirty_sources(conn) -> Vec<(SourceType, i64)>
2. If empty, break (queue fully drained)
3. For each (source_type, source_id):
   a. Begin transaction
   b. Call regenerate_one_tx(&tx, source_type, source_id) -> Result<bool>
   c. If Ok(changed): clear_dirty_tx, commit, count regenerated or unchanged
   d. If Err: record_dirty_error_tx (with backoff), commit, count errored

**regenerate_one_tx (per PRD):**
1. Extract document via extract_{type}_document(conn, source_id)
2. If None (deleted): delete_document, return Ok(true)
3. If Some(doc): call get_existing_hash() to check current state
4. **If ALL THREE hashes match: return Ok(false) — skip ALL writes** (fast path)
5. Otherwise: upsert_document with conditional label/path relinking
6. Return Ok(content changed)

**Helper functions (PRD-exact):**

`get_existing_hash` — uses `optional()` to distinguish missing rows from DB errors:
```rust
fn get_existing_hash(
    conn: &Connection,
    source_type: SourceType,
    source_id: i64,
) -> Result<Option<String>> {
    use rusqlite::OptionalExtension;
    let hash: Option<String> = stmt
        .query_row(params, |row| row.get(0))
        .optional()?;  // IMPORTANT: Not .ok() — .ok() would hide real DB errors
    Ok(hash)
}
```

`get_document_id` — resolve document ID after upsert:
```rust
fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<i64>
```

`upsert_document` — checks existing triple hash before writing:
```rust
fn upsert_document(conn: &Connection, doc: &DocumentData) -> Result<()> {
    // 1. Query existing (id, content_hash, labels_hash, paths_hash) via OptionalExtension
    // 2. Triple-hash fast path: all match -> return Ok(())
    // 3. Upsert document row (ON CONFLICT DO UPDATE)
    // 4. Get doc_id (from existing or query after insert)
    // 5. Only delete+reinsert labels if labels_hash changed
    // 6. Only delete+reinsert paths if paths_hash changed
}
```

**Key PRD detail — triple-hash fast path:**
```rust
if old_content_hash == &doc.content_hash
    && old_labels_hash == &doc.labels_hash
    && old_paths_hash == &doc.paths_hash
{ return Ok(()); }  // Skip ALL writes — prevents WAL churn
```

**Error recording with backoff:**
record_dirty_error_tx reads current attempt_count from DB, computes next_attempt_at via shared backoff utility:
```rust
let next_attempt_at = crate::core::backoff::compute_next_attempt_at(now, attempt_count + 1);
```

**All internal functions use _tx suffix** (take &Transaction) for atomicity.

## Acceptance Criteria
- [ ] Queue fully drained (bounded batch loop until empty)
- [ ] Per-item transactions (crash loses at most 1 doc)
- [ ] Triple-hash fast path: ALL THREE hashes match -> skip ALL writes (return Ok(false))
- [ ] Content change: upsert document, update labels/paths
- [ ] Labels-only change: relabels but skips path writes (paths_hash unchanged)
- [ ] Deleted entity: delete document (cascade handles FTS/labels/paths/embeddings)
- [ ] get_existing_hash uses `.optional()` (not `.ok()`) to preserve DB errors
- [ ] get_document_id resolves document ID after upsert
- [ ] Error recording: increment attempt_count, compute next_attempt_at via backoff
- [ ] FTS triggers fire on insert/update/delete (verified by trigger, not regenerator)
- [ ] RegenerateResult counts accurate (regenerated, unchanged, errored)
- [ ] Errors do not abort batch (log, increment, continue)
- [ ] `cargo test regenerator` passes

## Files
- `src/documents/regenerator.rs` — new file
- `src/documents/mod.rs` — add `pub use regenerator::regenerate_dirty_documents;`

## TDD Loop
RED: Tests requiring DB:
- `test_creates_new_document` — dirty source -> document created
- `test_skips_unchanged_triple_hash` — all 3 hashes match -> unchanged count incremented, no DB writes
- `test_updates_changed_content` — content_hash mismatch -> updated
- `test_updates_changed_labels_only` — content same but labels_hash different -> updated
- `test_updates_changed_paths_only` — content same but paths_hash different -> updated
- `test_deletes_missing_source` — source deleted -> document deleted
- `test_drains_queue` — queue empty after regeneration
- `test_error_records_backoff` — error -> attempt_count incremented, next_attempt_at set
- `test_get_existing_hash_not_found` — returns Ok(None) for missing document
GREEN: Implement regenerate_dirty_documents + all helpers
VERIFY: `cargo test regenerator`

## Edge Cases
- Empty queue: return immediately with all-zero counts
- Extractor error for one item: record_dirty_error_tx, commit, continue
- Triple-hash prevents WAL churn on incremental syncs (most entities unchanged)
- Labels change but content does not: labels_hash mismatch triggers upsert with label relinking
- get_existing_hash on missing document: returns Ok(None) via .optional() (not DB error)
- get_existing_hash on corrupt DB: propagates real DB error (not masked by .ok())

Implemented document regenerator with triple-hash fast path, queue draining, fail-soft error handling + 5 tests


Implement DB upsert functions for resource events

## Background
Need to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).

## Approach
Create src/core/events_db.rs (new module) with three upsert functions:

```rust
use rusqlite::Connection;
use super::error::Result;

/// Upsert state events for an entity.
/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).
pub fn upsert_state_events(
    conn: &Connection,
    project_id: i64,          // local DB project id
    entity_type: &str,        // "issue" | "merge_request"
    entity_local_id: i64,     // local DB id of the issue/MR
    events: &[GitLabStateEvent],
) -> Result<usize>

/// Upsert label events for an entity.
pub fn upsert_label_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabLabelEvent],
) -> Result<usize>

/// Upsert milestone events for an entity.
pub fn upsert_milestone_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabMilestoneEvent],
) -> Result<usize>
```

Each function:
1. Prepares INSERT OR REPLACE statement
2. For each event, maps GitLab types to DB columns:
   - `actor_gitlab_id` = event.user.map(|u| u.id)
   - `actor_username` = event.user.map(|u| u.username.clone())
   - `created_at` = parse_datetime_to_ms(&event.created_at)?
   - Set issue_id or merge_request_id based on entity_type
3. Returns count of upserted rows
4. Wraps in a savepoint for atomicity per entity

Register module in src/core/mod.rs:
```rust
pub mod events_db;
```

## Acceptance Criteria
- [ ] All three upsert functions compile and handle all event fields
- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)
- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms
- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)
- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)
- [ ] source_merge_request_id populated for state events (iid from source_merge_request)
- [ ] source_commit populated for state events
- [ ] label_name populated for label events
- [ ] milestone_title and milestone_id populated for milestone events
- [ ] Returns upserted count

## Files
- src/core/events_db.rs (new)
- src/core/mod.rs (add `pub mod events_db;`)

## TDD Loop
RED: tests/events_db_tests.rs (new):
- `test_upsert_state_events_basic` - insert 3 events, verify count and data
- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates
- `test_upsert_label_events_with_actor` - verify actor fields populated
- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash
- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set

Setup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.

GREEN: Implement the three functions

VERIFY: `cargo test events_db -- --nocapture`

## Edge Cases
- parse_datetime_to_ms must handle GitLab's format: "2024-03-15T10:30:00.000Z" and "2024-03-15T10:30:00.000+00:00"
- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe
- entity_type must be validated ("issue" or "merge_request") — panic or error on invalid
- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column

Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.


[CP0] Final validation - tests, lint, typecheck

## Background

Final validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.

Reference: docs/prd/checkpoint-0.md sections "Definition of Done", "Manual Smoke Tests"

## Approach

**Automated checks:**
```bash
# All tests pass
npm run test

# TypeScript strict mode
npm run build  # or: npx tsc --noEmit

# ESLint with no errors
npm run lint
```

**Manual smoke tests (from PRD table):**

| Command | Expected | Pass Criteria |
|---------|----------|---------------|
| `gi --help` | Command list | Shows all commands |
| `gi version` | Version number | Shows installed version |
| `gi init` | Interactive prompts | Creates valid config |
| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |
| `gi init --force` | No prompt | Overwrites without asking |
| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |
| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |
| `gi doctor` | Status table | All required checks pass |
| `gi doctor --json` | JSON object | Valid JSON, `success: true` |
| `gi backup` | Backup path | Creates timestamped backup |
| `gi sync-status` | No runs message | Stub output works |

**Definition of Done gate items:**
- [ ] `gi init` writes config to XDG path and validates projects against GitLab
- [ ] `gi auth-test` succeeds with real PAT
- [ ] `gi doctor` reports DB ok + GitLab ok
- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set
- [ ] App lock mechanism works (concurrent runs blocked)
- [ ] All unit tests pass
- [ ] All integration tests pass (mocked)
- [ ] ESLint passes with no errors
- [ ] TypeScript compiles with strict mode

## Acceptance Criteria

- [ ] `npm run test` exits 0 (all tests pass)
- [ ] `npm run build` exits 0 (TypeScript compiles)
- [ ] `npm run lint` exits 0 (no ESLint errors)
- [ ] All 11 manual smoke tests pass
- [ ] All 9 Definition of Done gate items verified

## Files

No new files created. This bead verifies existing work.

## TDD Loop

This IS the final verification step:

```bash
# Automated
npm run test
npm run build
npm run lint

# Manual (requires GITLAB_TOKEN set with valid token)
gi --help
gi version
gi init  # go through setup
gi auth-test
gi doctor
gi doctor --json | jq .success  # should output true
gi backup
gi sync-status
gi reset --confirm
gi init  # re-setup
```

## Edge Cases

- Test coverage should be reasonable (aim for 80%+ on core modules)
- Integration tests may flake on CI - check MSW setup
- Manual tests require real GitLab token - document in README
- ESLint may warn vs error - only errors block
- TypeScript noImplicitAny catches missed types


Update robot-docs manifest with Phase B commands

## Background

The robot-docs manifest is the agent self-discovery mechanism. It must include all Phase B commands so agents can discover temporal intelligence features.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns JSON with commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 commands documented in the manifest
- VALID_COMMANDS array in src/main.rs (line ~448): ["issues", "mrs", "search", "sync", "ingest", "count", "status", "auth", "doctor", "version", "init", "stats", "generate-docs", "embed", "migrate", "health", "robot-docs", "completions"]
- Phase B adds 3 new commands: timeline, file-history, trace
- count gains new entity: "references" (bd-2ez)
- Existing workflows: first_setup, daily_sync, search, pre_flight

## Approach

### 1. Add commands to handle_robot_docs() JSON:

```json
"timeline": {
    "description": "Chronological timeline of events matching a keyword query",
    "flags": ["<QUERY>", "-p <project>", "--since <duration>", "--depth <n>", "--expand-mentions", "-n <limit>"],
    "example": "lore --robot timeline 'authentication' --since 30d"
},
"file-history": {
    "description": "Which MRs touched a file, with rename chain resolution",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "--merged", "-n <limit>"],
    "example": "lore --robot file-history src/auth/oauth.rs"
},
"trace": {
    "description": "Trace file -> MR -> issue -> discussions decision chain",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "-n <limit>"],
    "example": "lore --robot trace src/auth/oauth.rs"
}
```

### 2. Update count command to mention "references" entity

### 3. Add temporal_intelligence workflow:
```json
"temporal_intelligence": {
    "description": "Query temporal data about project history",
    "steps": [
        "lore sync (ensure events fetched with fetchResourceEvents=true)",
        "lore timeline '<keyword>' for chronological event history",
        "lore file-history <path> for file-level MR history",
        "lore trace <path> for file -> MR -> issue -> discussion chain"
    ]
}
```

### 4. Add timeline, file-history, trace to VALID_COMMANDS array

## Acceptance Criteria

- [ ] robot-docs includes timeline, file-history, trace commands
- [ ] count references documented
- [ ] temporal_intelligence workflow present
- [ ] VALID_COMMANDS includes all 3 new commands
- [ ] Examples are valid, runnable commands
- [ ] cargo check --all-targets passes
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files

- src/main.rs (update handle_robot_docs + VALID_COMMANDS array)

## TDD Loop

VERIFY: lore robot-docs | jq '.data.commands.timeline'
VERIFY: lore robot-docs | jq '.data.workflows.temporal_intelligence'


Add WorkItemStatus type and SyncConfig toggle

## Background
The GraphQL status response returns name, category, color, and iconName fields. We need a Rust struct that deserializes this directly. Category is stored as raw Option<String> (not an enum) because GitLab 18.5+ supports custom statuses with arbitrary category values. We also need a config toggle so users can disable status enrichment.

## Approach
Add WorkItemStatus to the existing types module. Add fetch_work_item_status to the existing SyncConfig with default_true() helper. Also add WorkItemStatus to pub use re-exports in src/gitlab/mod.rs.

## Files
- src/gitlab/types.rs (add struct after GitLabMergeRequest, before #[cfg(test)])
- src/core/config.rs (add field to SyncConfig struct + Default impl)
- src/gitlab/mod.rs (add WorkItemStatus to pub use)

## Implementation

In src/gitlab/types.rs (needs Serialize, Deserialize derives already in scope):
  #[derive(Debug, Clone, Serialize, Deserialize)]
  pub struct WorkItemStatus {
      pub name: String,
      pub category: Option<String>,
      pub color: Option<String>,
      #[serde(rename = "iconName")]
      pub icon_name: Option<String>,
  }

In src/core/config.rs SyncConfig struct (after fetch_mr_file_changes):
  #[serde(rename = "fetchWorkItemStatus", default = "default_true")]
  pub fetch_work_item_status: bool,

In impl Default for SyncConfig (after fetch_mr_file_changes: true):
  fetch_work_item_status: true,

## Acceptance Criteria
- [ ] WorkItemStatus deserializes: {"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}
- [ ] Optional fields: {"name":"To do"} -> category/color/icon_name are None
- [ ] Unknown category: {"name":"Custom","category":"SOME_FUTURE_VALUE"} -> Ok
- [ ] Null category: {"name":"In progress","category":null} -> None
- [ ] SyncConfig::default().fetch_work_item_status == true
- [ ] JSON without fetchWorkItemStatus key -> defaults true
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_work_item_status_deserialize, test_work_item_status_optional_fields, test_work_item_status_unknown_category, test_work_item_status_null_category, test_config_fetch_work_item_status_default_true, test_config_deserialize_without_key
GREEN: Add struct + config field
VERIFY: cargo test test_work_item_status && cargo test test_config

## Edge Cases
- serde rename "iconName" -> icon_name (camelCase in GraphQL)
- Category is Option<String>, NOT an enum
- Config key is camelCase "fetchWorkItemStatus" matching existing convention

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


Write decay and scoring example-based tests (TDD)

## Background
All implementation beads (bd-1soz through bd-11mg) now include their own inline TDD tests. This bead is the integration verification: run the full test suite and confirm everything works together with no regressions.

## Approach
Run cargo test and verify:
1. All NEW tests pass (2 + 1 + 1 + 2 + 13 + 8 = 27 tests across implementation beads)
2. All EXISTING tests pass unchanged (existing who tests, config tests, etc.)
3. No test interference (--test-threads=1 mode)
4. All tests in who.rs test module compile and run cleanly

This is NOT a code-writing bead — it's a verification checkpoint.

## Acceptance Criteria
- [ ] cargo test -p lore passes (all tests green)
- [ ] cargo test -p lore -- --test-threads=1 passes (no test interference)
- [ ] No existing test assertions were changed
- [ ] Total test count: existing + 27 new = all pass

## TDD Loop
N/A — this bead verifies, doesn't write code.
VERIFY: `cargo test -p lore`

## Files
None modified — read-only verification.


Implement lore sync CLI command

## Background
The sync command is the unified orchestrator for the full pipeline: ingest -> generate-docs -> embed. It replaces the need to run three separate commands. It acquires a lock, runs each stage sequentially, and reports combined results. Individual stages can be skipped via flags (--no-embed, --no-docs). The command is designed for cron/scheduled execution. Individual commands (`lore generate-docs`, `lore embed`) still exist for manual recovery and debugging.

## Approach
Create `src/cli/commands/sync.rs` per PRD Section 6.4.

**IMPORTANT: run_sync is async** (embed_documents and search_hybrid are async).

**Key types (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct SyncResult {
    pub issues_updated: usize,
    pub mrs_updated: usize,
    pub discussions_fetched: usize,
    pub documents_regenerated: usize,
    pub documents_embedded: usize,
}

#[derive(Debug, Default)]
pub struct SyncOptions {
    pub full: bool,       // Reset cursors, fetch everything
    pub force: bool,      // Override stale lock
    pub no_embed: bool,   // Skip embedding step
    pub no_docs: bool,    // Skip document regeneration
}
```

**Core function (async, PRD-exact):**
```rust
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult>
```

**Pipeline (sequential steps per PRD):**
1. Acquire app lock with heartbeat (via existing `src/core/lock.rs`)
2. Ingest delta: fetch issues + MRs via cursor-based sync (calls existing ingestion orchestrator)
   - Each upserted entity marked dirty via `mark_dirty_tx(&tx)` inside ingestion transaction
3. Process `pending_discussion_fetches` queue (bounded)
   - Discussion sweep uses CTE to capture stale IDs, then cascading deletes
4. Regenerate documents from `dirty_sources` queue (unless --no-docs)
5. Embed documents with changed content_hash (unless --no-embed; skipped gracefully if Ollama unavailable)
6. Release lock, record sync_run

**NOTE (PRD):** Rolling backfill window removed — the existing cursor + watermark design handles old issues with resumed activity. GitLab updates `updated_at` when new comments are added, so the cursor naturally picks up old issues that receive new activity.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct SyncArgs {
    /// Reset cursors, fetch everything
    #[arg(long)]
    full: bool,
    /// Override stale lock
    #[arg(long)]
    force: bool,
    /// Skip embedding step
    #[arg(long)]
    no_embed: bool,
    /// Skip document regeneration
    #[arg(long)]
    no_docs: bool,
}
```

**Human output:**
```
Sync complete:
  Issues updated:             42
  MRs updated:                18
  Discussions fetched:        56
  Documents regenerated:      38
  Documents embedded:         38
  Elapsed: 2m 15s
```

**JSON output:**
```json
{"ok": true, "data": {...}, "meta": {"elapsed_ms": 135000}}
```

## Acceptance Criteria
- [ ] Function is `async fn run_sync`
- [ ] Takes `SyncOptions` struct (not separate params)
- [ ] Returns `SyncResult` with flat fields (not nested sub-structs)
- [ ] Full pipeline orchestrated: ingest -> discussion queue -> docs -> embed
- [ ] --full resets cursors (passes through to ingest)
- [ ] --force overrides stale sync lock
- [ ] --no-embed skips embedding stage (Ollama not needed)
- [ ] --no-docs skips document regeneration stage
- [ ] Discussion queue processing bounded per run
- [ ] Dirty sources marked inside ingestion transactions (via mark_dirty_tx)
- [ ] Progress reporting: stage names + elapsed time
- [ ] Lock acquired with heartbeat at start, released at end (even on error)
- [ ] Embedding skipped gracefully if Ollama unavailable (warning, not error)
- [ ] JSON summary in robot mode
- [ ] Human-readable summary with elapsed time
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/sync.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod sync;`
- `src/cli/mod.rs` — add SyncArgs, wire up sync subcommand
- `src/main.rs` — add sync command handler (async dispatch)

## TDD Loop
RED: Integration test requiring full pipeline
GREEN: Implement run_sync orchestration (async)
VERIFY: `cargo build && cargo test sync`

## Edge Cases
- Ollama unavailable + --no-embed not set: sync should NOT fail — embed stage logs warning, returns 0 embedded
- Lock already held: error unless --force (and lock is stale)
- No dirty sources after ingest: regeneration stage returns 0 (not error)
- --full with large dataset: keyset pagination prevents OFFSET degradation

Sync CLI: async run_sync orchestrator with 4-stage pipeline (ingest issues, ingest MRs, generate-docs, embed), SyncOptions/SyncResult, --full/--force/--no-embed/--no-docs flags, graceful Ollama degradation, human+JSON output, clean build, all tests pass


Write invariant tests for ranking system

## Background
Invariant tests catch subtle ranking regressions that example-based tests miss. These test properties that must hold for ANY input, not specific values.

## Approach

### test_score_monotonicity_by_age:
Generate 50 random (age_ms, half_life_days) pairs using a simple LCG PRNG (deterministic seed for reproducibility). Assert decay(older) <= decay(newer) for all pairs where older > newer. Tests the pure half_life_decay() function only.

### test_row_order_independence:
Insert the same 5 signals in two orderings (forward and reverse). Run query_expert on both -> assert identical username ordering and identical scores (f64 bit-equal). Use a deterministic dataset with varied timestamps.

### test_reviewer_split_is_exhaustive:
Set up 3 reviewers on the same MR:
1. Reviewer with substantive DiffNotes (>= 20 chars) -> must appear in participated ONLY
2. Reviewer with no DiffNotes -> must appear in assigned-only ONLY
3. Reviewer with trivial note (< 20 chars) -> must appear in assigned-only ONLY
Use --explain-score to verify each reviewer's components: participated reviewer has reviewer_participated > 0 and reviewer_assigned == 0; others have reviewer_assigned > 0 and reviewer_participated == 0.

### test_deterministic_accumulation_order:
Insert signals for one user with 15 MRs at varied timestamps. Run query_expert 100 times in a loop. Assert all 100 runs produce the exact same f64 score (use == not approx, to verify bit-identical results from sorted accumulation).

## Acceptance Criteria
- [ ] All 4 tests pass
- [ ] No flakiness across 10 consecutive cargo test runs
- [ ] test_score_monotonicity covers at least 50 random pairs
- [ ] test_deterministic_accumulation runs at least 100 iterations

## Files
- src/cli/commands/who.rs (test module)

## Edge Cases
- LCG PRNG for monotonicity test: use fixed seed, not rand crate (avoid dependency)
- Bit-identical f64: use assert_eq!(a, b) not approx — the deterministic ordering guarantees this
- Row order test: must insert in genuinely different orders, not just shuffled within same transaction

Tests distributed to implementation beads: monotonicity->bd-1soz, row_order+split+deterministic->bd-13q8


Implement chunk ID encoding module

## Background
sqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.

## Approach
Create `src/embedding/chunk_ids.rs`:

```rust
/// Multiplier for encoding (document_id, chunk_index) into a single rowid.
/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).
pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;

/// Encode (document_id, chunk_index) into a sqlite-vec rowid.
///
/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
    document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
}

/// Decode a sqlite-vec rowid back into (document_id, chunk_index).
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
    let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
    let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
    (document_id, chunk_index)
}
```

Also create the parent module `src/embedding/mod.rs`:
```rust
pub mod chunk_ids;
// Later beads add: pub mod ollama; pub mod pipeline;
```

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

## Acceptance Criteria
- [ ] `encode_rowid(42, 0)` == 42000
- [ ] `encode_rowid(42, 5)` == 42005
- [ ] `decode_rowid(42005)` == (42, 5)
- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs
- [ ] CHUNK_ROWID_MULTIPLIER is 1000
- [ ] `cargo test chunk_ids` passes

## Files
- `src/embedding/chunk_ids.rs` — new file
- `src/embedding/mod.rs` — new file (module root)
- `src/lib.rs` — add `pub mod embedding;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_encode_single_chunk` — encode(1, 0) == 1000
- `test_encode_multi_chunk` — encode(1, 5) == 1005
- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices
- `test_decode_zero_chunk` — decode(42000) == (42, 0)
- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000
GREEN: Implement encode_rowid, decode_rowid
VERIFY: `cargo test chunk_ids`

## Edge Cases
- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.
- document_id = 0: valid (encode returns chunk_index directly)

Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass


[CP1] GitLab types for issues, discussions, notes

Add TypeScript interfaces for GitLab API responses.

Types to add to src/gitlab/types.ts:
- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url
- GitLabDiscussion: id (string), individual_note, notes[]
- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?

Files: src/gitlab/types.ts
Done when: Types compile and match GitLab API documentation


Implement rename chain resolution for file-history

## Background

Rename chain resolution is the core algorithm for Gate 4. When querying history of src/auth.rs, it finds MRs that touched the file when it was previously named src/authentication.rs. This is reused by Gate 5 (trace) as well.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.6 (Rename Handling).

## Codebase Context

- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- change_type='renamed' rows have both old_path and new_path populated
- Partial index `idx_mfc_renamed` on (project_id, change_type) WHERE change_type='renamed' optimizes BFS queries
- Also `idx_mfc_project_path` on (project_id, new_path) and `idx_mfc_project_old_path` partial index
- No timeline/trace/file_history modules exist yet in src/core/

## Approach

Create `src/core/file_history.rs`:

```rust
use std::collections::HashSet;
use std::collections::VecDeque;
use rusqlite::Connection;
use crate::core::error::Result;

/// Resolves a file path through its rename history.
/// Returns all equivalent paths (original + renames) for use in queries.
/// BFS in both directions: forward (old_path -> new_path) and backward (new_path -> old_path).
pub fn resolve_rename_chain(
    conn: &Connection,
    project_id: i64,
    path: &str,
    max_hops: usize,       // default 10 from CLI
) -> Result<Vec<String>> {
    let mut visited: HashSet<String> = HashSet::new();
    let mut queue: VecDeque<String> = VecDeque::new();

    visited.insert(path.to_string());
    queue.push_back(path.to_string());

    let forward_sql = "SELECT mfc.new_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
    let backward_sql = "SELECT mfc.old_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";

    while let Some(current) = queue.pop_front() {
        if visited.len() > max_hops + 1 { break; }

        // Forward: current was the old name -> discover new names
        let mut stmt = conn.prepare(forward_sql)?;
        let forward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        // Backward: current was the new name -> discover old names
        let mut stmt = conn.prepare(backward_sql)?;
        let backward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        for discovered in forward.into_iter().chain(backward) {
            if visited.insert(discovered.clone()) {
                queue.push_back(discovered);
            }
        }
    }

    Ok(visited.into_iter().collect())
}
```

Register in `src/core/mod.rs`: add `pub mod file_history;`

## Acceptance Criteria

- [ ] `resolve_rename_chain()` follows renames in both directions (forward + backward)
- [ ] Cycles detected via HashSet (same path never visited twice)
- [ ] Bounded at max_hops (default 10)
- [ ] No renames found: returns vec with just the original path
- [ ] max_hops=0: returns just original path without querying DB
- [ ] Module registered in src/core/mod.rs as `pub mod file_history;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/file_history.rs` (NEW)
- `src/core/mod.rs` (add `pub mod file_history;`)

## TDD Loop

RED:
- `test_rename_chain_no_renames` — returns just original path
- `test_rename_chain_forward` — a.rs -> b.rs -> c.rs: starting from a.rs finds all three
- `test_rename_chain_backward` — starting from c.rs finds a.rs and b.rs
- `test_rename_chain_cycle_detection` — a->b->a terminates without infinite loop
- `test_rename_chain_max_hops_zero` — returns just original path
- `test_rename_chain_max_hops_bounded` — chain longer than max is truncated

Tests need in-memory DB with migrations applied through 016 + mr_file_changes test data with change_type='renamed'.

GREEN: Implement BFS with visited set.

VERIFY: `cargo test --lib -- file_history`

## Edge Cases

- File never renamed: single-element vec
- Circular rename (a->b->a): visited set prevents infinite loop
- max_hops=0: return just original path, no queries executed
- Case sensitivity: paths are case-sensitive (Linux default, matches GitLab behavior)
- Multiple renames from same old_path: BFS discovers all branches


Implement MR document extraction

## Background
MR documents are similar to issue documents but include source/target branch information in the header. The extractor queries merge_requests and mr_labels tables. Like issue extraction, it produces a DocumentData struct for the regeneration pipeline.

## Approach
Implement `extract_mr_document()` in `src/documents/extractor.rs`:

```rust
/// Extract a searchable document from a merge request.
/// Returns None if the MR has been deleted from the DB.
pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Main entity
SELECT m.id, m.iid, m.title, m.description, m.state, m.author_username,
       m.source_branch, m.target_branch,
       m.created_at, m.updated_at, m.web_url,
       p.path_with_namespace, p.id AS project_id
FROM merge_requests m
JOIN projects p ON p.id = m.project_id
WHERE m.id = ?

-- Labels
SELECT l.name FROM mr_labels ml
JOIN labels l ON l.id = ml.label_id
WHERE ml.merge_request_id = ?
ORDER BY l.name
```

**Document format:**
```
[[MergeRequest]] !456: Implement JWT authentication
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/merge_requests/456
Labels: ["feature", "auth"]
State: opened
Author: @johndoe
Source: feature/jwt-auth -> main

--- Description ---

This MR implements JWT-based authentication...
```

**Key difference from issues:** The `Source:` line with `source_branch -> target_branch`.

## Acceptance Criteria
- [ ] Deleted MR returns Ok(None)
- [ ] MR document has `[[MergeRequest]]` prefix with `!` before iid (not `#`)
- [ ] Source line shows `source_branch -> target_branch`
- [ ] Labels sorted alphabetically in JSON array
- [ ] content_hash computed from full content_text
- [ ] labels_hash computed from sorted labels
- [ ] paths is empty (MR-level docs don't have DiffNote paths; those are on discussion docs)
- [ ] `cargo test extract_mr` passes

## Files
- `src/documents/extractor.rs` — implement `extract_mr_document()`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_mr_document_format` — verify header matches PRD template with Source line
- `test_mr_not_found` — returns Ok(None)
- `test_mr_no_description` — header only
- `test_mr_branch_info` — Source line correct
GREEN: Implement extract_mr_document with SQL queries
VERIFY: `cargo test extract_mr`

## Edge Cases
- MR with NULL description: skip "--- Description ---" section
- MR with NULL source_branch or target_branch: omit Source line (shouldn't happen in practice)
- Draft MRs: state field captures this, no special handling needed

Implemented extract_mr_document() with Source line, PRD format, and 5 tests


OBSERV: Enrich robot JSON meta with run_id and stages

## Background
Robot JSON currently has a flat meta.elapsed_ms. This enriches it with run_id and a stages array, making every lore --robot sync output a complete performance profile.

## Approach
The robot JSON output is built in src/cli/commands/sync.rs. The current SyncResult (line 15-25) is serialized into the data field. The meta field is built alongside it.

1. Find or create the SyncMeta struct (likely near SyncResult). Add fields:
```rust
#[derive(Debug, Serialize)]
struct SyncMeta {
    run_id: String,
    elapsed_ms: u64,
    stages: Vec<StageTiming>,
}
```

2. After run_sync() completes, extract timings from MetricsLayer:
```rust
let stages = metrics_handle.extract_timings();
let meta = SyncMeta {
    run_id: run_id.to_string(),
    elapsed_ms: start.elapsed().as_millis() as u64,
    stages,
};
```

3. Build the JSON envelope:
```rust
let output = serde_json::json!({
    "ok": true,
    "data": result,
    "meta": meta,
});
```

The metrics_handle (Arc<MetricsLayer>) must be passed from main.rs to the command handler. This requires adding a parameter to handle_sync_cmd() and run_sync(), or using a global. Prefer parameter passing.

Same pattern for standalone ingest: add stages to IngestMeta.

## Acceptance Criteria
- [ ] lore --robot sync output includes meta.run_id (string, 8 hex chars)
- [ ] lore --robot sync output includes meta.stages (array of StageTiming)
- [ ] meta.elapsed_ms still present (total wall clock time)
- [ ] Each stage has name, elapsed_ms, items_processed at minimum
- [ ] Top-level stages have sub_stages when applicable
- [ ] lore --robot ingest also includes run_id and stages
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (add SyncMeta struct, wire extract_timings)
- src/cli/commands/ingest.rs (same for standalone ingest)
- src/main.rs (pass metrics_handle to command handlers)

## TDD Loop
RED: test_sync_meta_includes_stages (run robot-mode sync, parse JSON, assert meta.stages is array)
GREEN: Add SyncMeta, extract timings, include in JSON output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Empty stages: if sync runs with --no-docs --no-embed, some stages won't exist. stages array is shorter, not padded.
- extract_timings() called before root span closes: returns incomplete tree. Must call AFTER run_sync returns (span is dropped on function exit).
- metrics_handle clone: MetricsLayer uses Arc internally, clone is cheap (reference count increment).

Wired MetricsLayer into subscriber stack (all 4 branches), added run_id to SyncResult, enriched SyncMeta with run_id + stages Vec<StageTiming>, updated print_sync_json to accept MetricsLayer and extract timings


Display assignees, due_date, and milestone in lore issues <id> output

## Background
The `lore issues <id>` command displays issue details but omits key metadata that exists in the database: assignees, due dates, and milestones. Users need this information to understand issue context without opening GitLab.

**System fit**: This data is already ingested during issue sync (migration 005) but the show command never queries it.

## Approach

All changes in `src/cli/commands/show.rs`:

### 1. Update IssueRow struct (line ~119)
Add fields to internal row struct:
```rust
struct IssueRow {
    // ... existing 10 fields ...
    due_date: Option<String>,        // NEW
    milestone_title: Option<String>, // NEW
}
```

### 2. Update find_issue() SQL (line ~137)
Extend SELECT:
```sql
SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
       i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
       i.due_date, i.milestone_title  -- ADD THESE
FROM issues i ...
```

Update row mapping to extract columns 10 and 11.

### 3. Add get_issue_assignees() (after get_issue_labels ~line 189)
```rust
fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT username FROM issue_assignees WHERE issue_id = ? ORDER BY username"
    )?;
    let assignees = stmt
        .query_map([issue_id], |row| row.get(0))?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(assignees)
}
```

### 4. Update IssueDetail struct (line ~59)
```rust
pub struct IssueDetail {
    // ... existing 12 fields ...
    pub assignees: Vec<String>,      // NEW
    pub due_date: Option<String>,    // NEW
    pub milestone: Option<String>,   // NEW
}
```

### 5. Update IssueDetailJson struct (line ~770)
Add same 3 fields with identical types.

### 6. Update run_show_issue() (line ~89)
```rust
let assignees = get_issue_assignees(&conn, issue.id)?;
// In return struct:
assignees,
due_date: issue.due_date,
milestone: issue.milestone_title,
```

### 7. Update print_show_issue() (line ~533, after Author line ~548)
```rust
if !issue.assignees.is_empty() {
    println!("Assignee{}: {}",
        if issue.assignees.len() > 1 { "s" } else { "" },
        issue.assignees.iter().map(|a| format!("@{}", a)).collect::<Vec<_>>().join(", "));
}
if let Some(due) = &issue.due_date {
    println!("Due:       {}", due);
}
if let Some(ms) = &issue.milestone {
    println!("Milestone: {}", ms);
}
```

### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)
```rust
assignees: issue.assignees.clone(),
due_date: issue.due_date.clone(),
milestone: issue.milestone.clone(),
```

## Acceptance Criteria
- [ ] `cargo test test_get_issue_assignees` passes (3 tests)
- [ ] `lore issues <id>` shows Assignees line when assignees exist
- [ ] `lore issues <id>` shows Due line when due_date set
- [ ] `lore issues <id>` shows Milestone line when milestone set
- [ ] `lore -J issues <id>` includes assignees/due_date/milestone in JSON
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- `src/cli/commands/show.rs` - ALL changes

## TDD Loop

**RED** - Add tests to `src/cli/commands/show.rs` `#[cfg(test)] mod tests`:

```rust
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;

fn setup_test_db() -> Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn
}

#[test]
fn test_get_issue_assignees_empty() {
    let conn = setup_test_db();
    // seed project + issue with no assignees
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_get_issue_assignees_multiple_sorted() {
    let conn = setup_test_db();
    // seed with alice, bob
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert_eq!(result, vec!["alice", "bob"]); // alphabetical
}

#[test]
fn test_get_issue_assignees_single() {
    let conn = setup_test_db();
    // seed with charlie only
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert_eq!(result, vec!["charlie"]);
}
```

**GREEN** - Implement get_issue_assignees() and struct updates

**VERIFY**: `cargo test test_get_issue_assignees && cargo clippy --all-targets -- -D warnings`

## Edge Cases
- Empty assignees list -> don't print Assignees line
- NULL due_date -> don't print Due line
- NULL milestone_title -> don't print Milestone line
- Single vs multiple assignees -> "Assignee" vs "Assignees" grammar

Implemented: assignees, due_date, milestone now display in lore issues <id>. All 7 new tests pass.


[CP1] Issue ingestion module

## Background

The issue ingestion module fetches and stores issues with cursor-based incremental sync. It is the primary data ingestion component, establishing the pattern reused for MR ingestion in CP2. The module handles tuple-cursor semantics, raw payload storage, label extraction, and tracking which issues need discussion sync.

## Approach

### Module: src/ingestion/issues.rs

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestIssuesResult {
    pub fetched: usize,
    pub upserted: usize,
    pub labels_created: usize,
    pub issues_needing_discussion_sync: Vec<IssueForDiscussionSync>,
}

#[derive(Debug, Clone)]
pub struct IssueForDiscussionSync {
    pub local_issue_id: i64,
    pub iid: i64,
    pub updated_at: i64,  // ms epoch
}
```

### Main Function

```rust
pub async fn ingest_issues(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,
) -> Result<IngestIssuesResult>
```

### Logic (Step by Step)

1. **Get current cursor** from sync_cursors table:
```sql
SELECT updated_at_cursor, tie_breaker_id
FROM sync_cursors
WHERE project_id = ? AND resource_type = 'issues'
```

2. **Call pagination method** with cursor rewind:
```rust
let issues_stream = client.paginate_issues(
    gitlab_project_id,
    cursor.updated_at_cursor,
    config.sync.cursor_rewind_seconds,
);
```

3. **Apply local filtering** for tuple cursor semantics:
```rust
// Skip if issue.updated_at < cursor_updated_at
// Skip if issue.updated_at == cursor_updated_at AND issue.gitlab_id <= cursor_gitlab_id
fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> bool {
    if issue.updated_at < cursor.updated_at_cursor {
        return false;
    }
    if issue.updated_at == cursor.updated_at_cursor
        && issue.gitlab_id <= cursor.tie_breaker_id {
        return false;
    }
    true
}
```

4. **For each issue passing filter**:
```rust
// Begin transaction (unchecked_transaction for rusqlite)
let tx = conn.unchecked_transaction()?;

// Store raw payload (compressed based on config)
let payload_id = store_raw_payload(&tx, &issue_json, config.storage.compress_raw_payloads)?;

// Transform and upsert issue
let issue_row = transform_issue(&issue)?;
upsert_issue(&tx, &issue_row, project_id, payload_id)?;
let local_issue_id = get_local_issue_id(&tx, project_id, issue.iid)?;

// Clear existing label links (stale removal!)
tx.execute("DELETE FROM issue_labels WHERE issue_id = ?", [local_issue_id])?;

// Extract and upsert labels
for label_name in &issue_row.label_names {
    let label_id = upsert_label(&tx, project_id, label_name)?;
    link_issue_label(&tx, local_issue_id, label_id)?;
}

tx.commit()?;
```

5. **Incremental cursor update** every 100 issues:
```rust
if batch_count % 100 == 0 {
    update_sync_cursor(conn, project_id, "issues", last_updated_at, last_gitlab_id)?;
}
```

6. **Final cursor update** after all issues processed

7. **Determine issues needing discussion sync**:
```sql
SELECT id, iid, updated_at
FROM issues
WHERE project_id = ?
  AND updated_at > COALESCE(discussions_synced_for_updated_at, 0)
```

### Helper Functions

```rust
fn store_raw_payload(conn, json: &Value, compress: bool) -> Result<i64>
fn upsert_issue(conn, issue: &IssueRow, project_id: i64, payload_id: i64) -> Result<()>
fn get_local_issue_id(conn, project_id: i64, iid: i64) -> Result<i64>
fn upsert_label(conn, project_id: i64, name: &str) -> Result<i64>
fn link_issue_label(conn, issue_id: i64, label_id: i64) -> Result<()>
fn update_sync_cursor(conn, project_id: i64, resource: &str, updated_at: i64, gitlab_id: i64) -> Result<()>
```

### Critical Invariant

Stale label links MUST be removed on resync. The "DELETE then INSERT" pattern ensures GitLab reality is reflected locally. If an issue had labels [A, B] and now has [A, C], the B link must be removed.

## Acceptance Criteria

- [ ] `ingest_issues` returns IngestIssuesResult with all counts
- [ ] Cursor fetched from sync_cursors at start
- [ ] Cursor rewind applied before API call
- [ ] Local filtering skips already-processed issues
- [ ] Each issue wrapped in transaction for atomicity
- [ ] Raw payload stored with correct compression
- [ ] Issue upserted (INSERT OR REPLACE pattern)
- [ ] Existing label links deleted before new links inserted
- [ ] Labels upserted (INSERT OR IGNORE by project+name)
- [ ] Cursor updated every 100 issues (crash recovery)
- [ ] Final cursor update after all issues
- [ ] issues_needing_discussion_sync populated correctly

## Files

- src/ingestion/mod.rs (add `pub mod issues;`)
- src/ingestion/issues.rs (create)

## TDD Loop

RED:
```rust
// tests/issue_ingestion_tests.rs
#[tokio::test] async fn ingests_issues_from_stream()
#[tokio::test] async fn applies_cursor_filter_correctly()
#[tokio::test] async fn updates_cursor_every_100_issues()
#[tokio::test] async fn stores_raw_payload_for_each_issue()
#[tokio::test] async fn upserts_issues_correctly()

// tests/label_linkage_tests.rs
#[tokio::test] async fn extracts_and_stores_labels()
#[tokio::test] async fn removes_stale_label_links_on_resync()
#[tokio::test] async fn handles_empty_labels_array()

// tests/discussion_eligibility_tests.rs
#[tokio::test] async fn identifies_issues_needing_discussion_sync()
#[tokio::test] async fn skips_issues_with_current_watermark()
```

GREEN: Implement ingest_issues with all helper functions

VERIFY: `cargo test issue_ingestion && cargo test label_linkage && cargo test discussion_eligibility`

## Edge Cases

- Empty issues stream - return result with all zeros
- Cursor at epoch 0 - fetch all issues (no filtering)
- Issue with no labels - empty Vec, no label links created
- Issue with 50+ labels - all should be linked
- Crash mid-batch - cursor at last 100-boundary, some issues re-fetched
- Label already exists - upsert via INSERT OR IGNORE
- Same issue fetched twice (due to rewind) - upsert handles it


Define TimelineEvent model and TimelineEventType enum

## Background

The TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.3 (Event Model).

## Codebase Context

- Migration 011 created: resource_state_events, resource_label_events, resource_milestone_events, entity_references, pending_dependent_fetches
- source_method CHECK constraint: `'api' | 'note_parse' | 'description_parse'` (NOT spec's 'api_closes_issues' etc.)
- reference_type CHECK constraint: `'closes' | 'mentioned' | 'related'`
- LATEST_SCHEMA_VERSION = 14

## Approach

Create `src/core/timeline.rs` with the following types:

```rust
/// The core timeline event. All pipeline stages produce or consume these.
/// Spec ref: Section 3.3 "Event Model"
#[derive(Debug, Clone, Serialize)]
pub struct TimelineEvent {
    pub timestamp: i64,                    // ms epoch UTC
    pub entity_type: String,               // "issue" | "merge_request"
    pub entity_id: i64,                    // local DB id (internal, not in JSON output)
    pub entity_iid: i64,
    pub project_path: String,
    pub event_type: TimelineEventType,
    pub summary: String,                   // human-readable one-liner
    pub actor: Option<String>,             // username or None for system
    pub url: Option<String>,               // web URL for the event source
    pub is_seed: bool,                     // true if from seed phase, false if expanded
}

/// Per spec Section 3.3. Serde tagged enum for JSON output.
/// IMPORTANT: entity_type is String (not &'static str) because serde Serialize
/// requires owned types for struct fields when deriving.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TimelineEventType {
    Created,
    StateChanged { state: String },        // spec: just the target state
    LabelAdded { label: String },
    LabelRemoved { label: String },
    MilestoneSet { milestone: String },
    MilestoneRemoved { milestone: String },
    Merged,                                // spec: unit variant
    NoteEvidence {
        note_id: i64,                      // spec: required
        snippet: String,                   // first ~200 chars of matching note body
        discussion_id: Option<i64>,        // spec: optional
    },
    CrossReferenced { target: String },    // compact target ref like "\!567" or "#234"
}

/// Internal entity reference used across pipeline stages.
#[derive(Debug, Clone, Serialize)]
pub struct EntityRef {
    pub entity_type: String,               // String not &'static str — needed for Serialize
    pub entity_id: i64,
    pub entity_iid: i64,
    pub project_path: String,
}

/// An entity discovered via BFS expansion.
/// Spec ref: Section 3.5 "expanded_entities" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct ExpandedEntityRef {
    pub entity_ref: EntityRef,
    pub depth: u32,
    pub via_from: EntityRef,               // the entity that referenced this one
    pub via_reference_type: String,        // "closes", "mentioned", "related"
    pub via_source_method: String,         // "api", "note_parse", "description_parse"
}

/// Reference to an unsynced external entity.
/// Spec ref: Section 3.5 "unresolved_references" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedRef {
    pub source: EntityRef,
    pub target_project: Option<String>,
    pub target_type: String,
    pub target_iid: i64,
    pub reference_type: String,
}

/// Complete result from the timeline pipeline.
#[derive(Debug, Clone, Serialize)]
pub struct TimelineResult {
    pub query: String,
    pub events: Vec<TimelineEvent>,
    pub seed_entities: Vec<EntityRef>,
    pub expanded_entities: Vec<ExpandedEntityRef>,
    pub unresolved_references: Vec<UnresolvedRef>,
}
```

Implement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then event_type discriminant.

Also implement `PartialEq`, `Eq`, `PartialOrd` (required by Ord).

Register in `src/core/mod.rs`: `pub mod timeline;`

## Acceptance Criteria

- [ ] `src/core/timeline.rs` compiles with no warnings
- [ ] All struct fields use `String` not `&'static str` (required for `#[derive(Serialize)]`)
- [ ] `TimelineEventType` has exactly 9 variants matching spec Section 3.3
- [ ] `NoteEvidence` has `note_id: i64`, `snippet: String`, `discussion_id: Option<i64>`
- [ ] `ExpandedEntityRef.via_source_method` documents codebase values: api, note_parse, description_parse
- [ ] `Ord` impl sorts by (timestamp, entity_id, event_type discriminant)
- [ ] `PartialEq`, `Eq`, `PartialOrd` derived or implemented
- [ ] Module registered in `src/core/mod.rs`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/timeline.rs` (NEW)
- `src/core/mod.rs` (add `pub mod timeline;`)

## TDD Loop

RED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests`:
- `test_timeline_event_sort_by_timestamp` - events sort chronologically
- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably
- `test_timeline_event_type_serializes_tagged` - serde JSON uses `kind` tag
- `test_note_evidence_has_note_id` - note_id present in serialized output

GREEN: Implement the types and Ord trait.

VERIFY: `cargo test --lib -- timeline`

## Edge Cases

- Ord must be consistent and total for all valid TimelineEvent pairs
- NoteEvidence snippet truncated to 200 chars at construction, not in the type
- entity_type uses String to satisfy serde Serialize derive requirements
- url field: constructed from project_path + entity_type + iid; None for entities without web_url

Completed: Created src/core/timeline.rs with TimelineEvent, TimelineEventType (9 variants), EntityRef, ExpandedEntityRef, UnresolvedRef, TimelineResult. Ord impl sorts by (timestamp, entity_id, event_type discriminant). entity_id skipped in serde output. 6 tests pass. All quality gates pass.


Implement MR discussion ingestion module

## Background
MR discussion ingestion with critical atomicity guarantees. Parse notes BEFORE destructive DB operations to prevent data loss. Watermark ONLY advanced on full success.

## Approach
Create `src/ingestion/mr_discussions.rs` with:
1. `IngestMrDiscussionsResult` - Per-MR stats
2. `ingest_mr_discussions()` - Main function with atomicity guarantees
3. Upsert + sweep pattern for notes (not delete-all-then-insert)
4. Sync health telemetry for debugging failures

## Files
- `src/ingestion/mr_discussions.rs` - New module
- `tests/mr_discussion_ingestion_tests.rs` - Integration tests

## Acceptance Criteria
- [ ] `IngestMrDiscussionsResult` has: discussions_fetched, discussions_upserted, notes_upserted, notes_skipped_bad_timestamp, diffnotes_count, pagination_succeeded
- [ ] `ingest_mr_discussions()` returns `Result<IngestMrDiscussionsResult>`
- [ ] CRITICAL: Notes parsed BEFORE any DELETE operations
- [ ] CRITICAL: Watermark NOT advanced if `pagination_succeeded == false`
- [ ] CRITICAL: Watermark NOT advanced if any note parse fails
- [ ] Upsert + sweep pattern using `last_seen_at`
- [ ] Stale discussions/notes removed only on full success
- [ ] Selective raw payload storage (skip system notes without position)
- [ ] Sync health telemetry recorded on failure
- [ ] `does_not_advance_discussion_watermark_on_partial_failure` test passes
- [ ] `atomic_note_replacement_preserves_data_on_parse_failure` test passes

## TDD Loop
RED: `cargo test does_not_advance_watermark` -> test fails
GREEN: Add ingestion with atomicity guarantees
VERIFY: `cargo test mr_discussion_ingestion`

## Main Function
```rust
pub async fn ingest_mr_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,
    gitlab_project_id: i64,
    mr_iid: i64,
    local_mr_id: i64,
    mr_updated_at: i64,
) -> Result<IngestMrDiscussionsResult>
```

## CRITICAL: Atomic Note Replacement
```rust
// Record sync start time for sweep
let run_seen_at = now_ms();

while let Some(discussion_result) = stream.next().await {
    let discussion = match discussion_result {
        Ok(d) => d,
        Err(e) => {
            result.pagination_succeeded = false;
            break; // Stop but don't advance watermark
        }
    };

    // CRITICAL: Parse BEFORE destructive operations
    let notes = match transform_notes_with_diff_position(&discussion, project_id) {
        Ok(notes) => notes,
        Err(e) => {
            warn!("Note transform failed; preserving existing notes");
            result.notes_skipped_bad_timestamp += discussion.notes.len();
            result.pagination_succeeded = false;
            continue; // Skip this discussion, don't delete existing
        }
    };

    // Only NOW start transaction (after parse succeeded)
    let tx = conn.unchecked_transaction()?;

    // Upsert discussion with run_seen_at
    // Upsert notes with run_seen_at (not delete-all)

    tx.commit()?;
}
```

## Stale Data Sweep (only on success)
```rust
if result.pagination_succeeded {
    // Sweep stale discussions
    conn.execute(
        "DELETE FROM discussions
         WHERE project_id = ? AND merge_request_id = ?
           AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;

    // Sweep stale notes
    conn.execute(
        "DELETE FROM notes
         WHERE discussion_id IN (
           SELECT id FROM discussions
           WHERE project_id = ? AND merge_request_id = ?
         )
         AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;
}
```

## Watermark Update (ONLY on success)
```rust
if result.pagination_succeeded {
    mark_discussions_synced(conn, local_mr_id, mr_updated_at)?;
    clear_sync_health_error(conn, local_mr_id)?;
} else {
    record_sync_health_error(conn, local_mr_id, "Pagination incomplete or parse failure")?;
    warn!("Watermark NOT advanced; will retry on next sync");
}
```

## Selective Payload Storage
```rust
// Only store payload for DiffNotes and non-system notes
let should_store_note_payload =
    !note.is_system() ||
    note.position_new_path().is_some() ||
    note.position_old_path().is_some();
```

## Integration Tests (CRITICAL)
```rust
#[tokio::test]
async fn does_not_advance_discussion_watermark_on_partial_failure() {
    // Setup: MR with updated_at > discussions_synced_for_updated_at
    // Mock: Page 1 returns OK, Page 2 returns 500
    // Assert: discussions_synced_for_updated_at unchanged
}

#[tokio::test]
async fn does_not_advance_discussion_watermark_on_note_parse_failure() {
    // Setup: Existing notes in DB
    // Mock: Discussion with note having invalid created_at
    // Assert: Original notes preserved, watermark unchanged
}

#[tokio::test]
async fn atomic_note_replacement_preserves_data_on_parse_failure() {
    // Setup: Discussion with 3 valid notes
    // Mock: Updated discussion where note 2 has bad timestamp
    // Assert: All 3 original notes still in DB
}
```

## Edge Cases
- HTTP error mid-pagination: preserve existing data, log error, no watermark advance
- Invalid note timestamp: skip discussion, preserve existing notes
- System notes without position: don't store raw payload (saves space)
- Empty discussion: still upsert discussion record, no notes

Implemented MR discussion ingestion module with full atomicity guarantees:
- IngestMrDiscussionsResult with all required fields
- parse-before-destructive pattern (transform notes before DB ops)
- Upsert + sweep pattern with last_seen_at timestamps
- Watermark advanced ONLY on full pagination success
- Selective payload storage (skip system notes without position)
- Sync health telemetry for failure debugging
- All 163 tests passing


Create migration 008_fts5.sql

## Background
FTS5 (Full-Text Search 5) provides the lexical search backbone for Gate A. The virtual table + triggers keep the FTS index in sync with the documents table automatically. This migration must be applied AFTER migration 007 (documents table exists). The trigger design handles NULL titles via COALESCE and only rebuilds the FTS entry when searchable text actually changes (not metadata-only updates).

## Approach
Create `migrations/008_fts5.sql` with the exact SQL from PRD Section 1.2:

1. **Virtual table:** `documents_fts` using FTS5 with porter stemmer, prefix indexes (2,3,4), external content backed by `documents` table
2. **Insert trigger:** `documents_ai` — inserts into FTS on document insert, uses COALESCE(title, '') for NULL safety
3. **Delete trigger:** `documents_ad` — removes from FTS on document delete using the FTS5 delete command syntax
4. **Update trigger:** `documents_au` — only fires when `title` or `content_text` changes (WHEN clause), performs delete-then-insert to update FTS

Register migration 8 in `src/core/db.rs` MIGRATIONS array.

**Critical detail:** The COALESCE is required because FTS5 external-content tables require exact value matching for delete operations. If NULL was inserted, the delete trigger couldn't match it (NULL != NULL in SQL).

## Acceptance Criteria
- [ ] `migrations/008_fts5.sql` file exists
- [ ] `documents_fts` virtual table created with `tokenize='porter unicode61'` and `prefix='2 3 4'`
- [ ] `content='documents'` and `content_rowid='id'` set (external content mode)
- [ ] Insert trigger `documents_ai` fires on document insert with COALESCE(title, '')
- [ ] Delete trigger `documents_ad` fires on document delete using FTS5 delete command
- [ ] Update trigger `documents_au` only fires when `old.title IS NOT new.title OR old.content_text != new.content_text`
- [ ] Prefix search works: query `auth*` matches "authentication"
- [ ] After bulk insert of N documents, `SELECT count(*) FROM documents_fts` returns N
- [ ] Schema version 8 recorded in schema_version table
- [ ] `cargo test migration_tests` passes

## Files
- `migrations/008_fts5.sql` — new file (copy exact SQL from PRD Section 1.2)
- `src/core/db.rs` — add migration 8 to MIGRATIONS array

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails (SQL file missing)
GREEN: Create `008_fts5.sql` with all triggers
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- Metadata-only updates (e.g., changing `updated_at` or `labels_hash`) must NOT trigger FTS rebuild — the WHEN clause prevents this
- NULL titles must use COALESCE to empty string in both insert and delete triggers
- The update trigger does delete+insert (not FTS5 'delete' + regular insert atomically) — this is the correct FTS5 pattern for content changes

Completed: migration 008_fts5.sql with FTS5 virtual table, 3 sync triggers (insert/delete/update with COALESCE NULL safety), prefix search, registered in db.rs, cargo build + tests pass


Epic: Time-Decay Expert Scoring Model

## Background

Replace flat-weight expertise scoring with exponential half-life decay, split reviewer signals (participated vs assigned-only), dual-path rename awareness, and new CLI flags (--as-of, --explain-score, --include-bots, --all-history).

**Plan document:** plans/time-decay-expert-scoring.md (iteration 5, target 8)

## Children (Execution Order)

### Layer 0 — Foundation (no deps)
- **bd-2w1p** — Add half-life fields and config validation to ScoringConfig
- **bd-1soz** — Add half_life_decay() pure function

### Layer 1 — Schema + Helpers (depends on Layer 0)
- **bd-2ao4** — Add migration for dual-path and reviewer participation indexes
- **bd-2yu5** — Add timestamp-aware test helpers
- **bd-1b50** — Update existing tests for new ScoringConfig fields

### Layer 2 — SQL + Path Probes (depends on Layer 1)
- **bd-1hoq** — Restructure expert SQL with CTE-based dual-path matching
- **bd-1h3f** — Add rename awareness to path resolution probes

### Layer 3 — Rust Aggregation (depends on Layer 2)
- **bd-13q8** — Implement Rust-side decay aggregation with reviewer split

### Layer 4 — CLI (depends on Layer 3)
- **bd-11mg** — Add CLI flags: --as-of, --explain-score, --include-bots, --all-history

### Layer 5 — Verification (depends on Layer 4)
- **bd-1vti** — Run full test suite and verify all 27+ new tests pass
- **bd-1j5o** — Quality gates, query plan check, real-world validation

## Files Modified
- src/core/config.rs (ScoringConfig struct, validation)
- src/cli/commands/who.rs (decay function, SQL, aggregation, CLI flags, tests)
- src/core/db.rs (migration registration)
- migrations/021_scoring_indexes.sql (new indexes)

## Acceptance Criteria
- [ ] All 27+ new tests pass (across all child beads)
- [ ] All existing tests pass unchanged (decay ~1.0 at now_ms())
- [ ] cargo check + clippy + fmt clean
- [ ] ubs scan clean on modified files
- [ ] EXPLAIN QUERY PLAN shows indexes used (manual verification)
- [ ] Real-world validation: who --path on known files shows recency discounting
- [ ] who --explain-score component breakdown sums to total
- [ ] who --as-of produces deterministic results across runs
- [ ] Assigned-only reviewers rank below participated reviewers
- [ ] Old file paths resolve and credit expertise after renames

## Edge Cases
- f64 NaN guard in half_life_decay (hl=0 -> 0.0)
- Deterministic f64 ordering via mr_id sort before summation
- Closed MR multiplier applied uniformly to all signal types
- Trivial notes (< reviewer_min_note_chars) classified as assigned-only
- Exclusive upper bound on --as-of prevents future event leakage


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

## Module
src/cli/commands/count.rs

## Clap Definition
Count {
    #[arg(value_parser = ["issues", "mrs", "discussions", "notes"])]
    entity: String,

    #[arg(long, value_parser = ["issue", "mr"])]
    r#type: Option<String>,
}

## Commands
- gi count issues → 'Issues: N'
- gi count discussions → 'Discussions: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes → 'Notes: N (excluding M system)'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

## Implementation
- Simple COUNT(*) queries
- For notes, also count WHERE is_system = 1 for system note count
- Filter by noteable_type when --type specified

Files: src/cli/commands/count.rs
Done when: Counts match expected values from GitLab


OBSERV: Implement SyncRunRecorder lifecycle helper

## Background
The sync_runs table exists (migration 001) but NOTHING writes to it. SyncRunRecorder encapsulates the INSERT-on-start, UPDATE-on-finish lifecycle, fixing this bug and enabling sync history tracking.

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

```rust
use crate::core::metrics::StageTiming;
use crate::core::error::Result;
use rusqlite::Connection;

pub struct SyncRunRecorder {
    row_id: i64,
}

impl SyncRunRecorder {
    /// Insert a new sync_runs row with status='running'.
    pub fn start(conn: &Connection, command: &str, run_id: &str) -> Result<Self> {
        let now_ms = crate::core::time::now_ms();
        conn.execute(
            "INSERT INTO sync_runs (started_at, heartbeat_at, status, command, run_id)
             VALUES (?1, ?2, 'running', ?3, ?4)",
            rusqlite::params![now_ms, now_ms, command, run_id],
        )?;
        let row_id = conn.last_insert_rowid();
        Ok(Self { row_id })
    }

    /// Mark run as succeeded with full metrics.
    pub fn succeed(
        self,
        conn: &Connection,
        metrics: &[StageTiming],
        total_items: usize,
        total_errors: usize,
    ) -> Result<()> {
        let now_ms = crate::core::time::now_ms();
        let metrics_json = serde_json::to_string(metrics)
            .unwrap_or_else(|_| "[]".to_string());
        conn.execute(
            "UPDATE sync_runs
             SET finished_at = ?1, status = 'succeeded',
                 metrics_json = ?2, total_items_processed = ?3, total_errors = ?4
             WHERE id = ?5",
            rusqlite::params![now_ms, metrics_json, total_items, total_errors, self.row_id],
        )?;
        Ok(())
    }

    /// Mark run as failed with error message and optional partial metrics.
    pub fn fail(
        self,
        conn: &Connection,
        error: &str,
        metrics: Option<&[StageTiming]>,
    ) -> Result<()> {
        let now_ms = crate::core::time::now_ms();
        let metrics_json = metrics
            .map(|m| serde_json::to_string(m).unwrap_or_else(|_| "[]".to_string()));
        conn.execute(
            "UPDATE sync_runs
             SET finished_at = ?1, status = 'failed', error = ?2,
                 metrics_json = ?3
             WHERE id = ?4",
            rusqlite::params![now_ms, error, metrics_json, self.row_id],
        )?;
        Ok(())
    }
}
```

Register in src/core/mod.rs:
```rust
pub mod sync_run;
```

Note: SyncRunRecorder takes self (not &self) in succeed/fail to enforce single-use lifecycle. You start a run, then either succeed or fail it -- never both.

The existing time::now_ms() helper (src/core/time.rs) returns milliseconds since epoch as i64. Used by the existing sync_runs schema (started_at, finished_at are INTEGER ms).

## Acceptance Criteria
- [ ] SyncRunRecorder::start() inserts row with status='running', started_at set
- [ ] SyncRunRecorder::succeed() updates status='succeeded', finished_at set, metrics_json populated
- [ ] SyncRunRecorder::fail() updates status='failed', error set, finished_at set
- [ ] fail() with Some(metrics) stores partial metrics in metrics_json
- [ ] fail() with None leaves metrics_json as NULL
- [ ] succeed/fail consume self (single-use enforcement)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/sync_run.rs (new file)
- src/core/mod.rs (register module)

## TDD Loop
RED:
  - test_sync_run_recorder_start: in-memory DB, start(), query sync_runs, assert status='running'
  - test_sync_run_recorder_succeed: start() then succeed(), assert status='succeeded', metrics_json parseable
  - test_sync_run_recorder_fail: start() then fail(), assert status='failed', error set
  - test_sync_run_recorder_fail_with_partial_metrics: fail with Some(metrics), assert metrics_json has data
GREEN: Implement SyncRunRecorder
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Connection lifetime: SyncRunRecorder stores row_id, not Connection. The caller must ensure the same Connection is used for start/succeed/fail.
- Panic during sync: if the program panics between start() and succeed()/fail(), the row stays as 'running'. The existing stale lock detection (stale_lock_minutes) handles this.
- metrics_json encoding: serde_json::to_string on Vec<StageTiming> produces a JSON array string.

Implemented SyncRunRecorder with start/succeed/fail lifecycle, 4 passing tests


OBSERV: Wire SyncRunRecorder into sync and ingest commands

## Background
With SyncRunRecorder implemented and MetricsLayer available, we wire them into the actual sync and ingest command handlers. This makes every sync/ingest invocation create a database record with full metrics.

## Approach
### src/cli/commands/sync.rs - run_sync() (line ~54)

Before the pipeline:
```rust
let recorder = SyncRunRecorder::start(&conn, "sync", &run_id)?;
```

After pipeline succeeds:
```rust
let stages = metrics_handle.extract_timings();
let total_items = stages.iter().map(|s| s.items_processed).sum::<usize>();
let total_errors = stages.iter().map(|s| s.errors).sum::<usize>();
recorder.succeed(&conn, &stages, total_items, total_errors)?;
```

On pipeline failure (wrap pipeline in match or use a helper):
```rust
match pipeline_result {
    Ok(result) => {
        let stages = metrics_handle.extract_timings();
        recorder.succeed(&conn, &stages, total_items, total_errors)?;
        Ok(result)
    }
    Err(e) => {
        let stages = metrics_handle.extract_timings();
        recorder.fail(&conn, &e.to_string(), Some(&stages))?;
        Err(e)
    }
}
```

### src/cli/commands/ingest.rs - run_ingest() (line ~107)

Same pattern: start before pipeline, succeed/fail after.

Note: run_sync() calls run_ingest() internally. Both will create sync_runs records. This is intentional -- standalone ingest should also be tracked. But when run_sync calls run_ingest, the ingest record is a child operation. Consider: should we skip the ingest recorder when called from sync? Decision: keep both records. The run_id differs, and sync-status can distinguish by the "command" column.

Actually, re-reading the code: run_sync() (line 54-178) calls run_ingest() for issues and MRs. If both create sync_runs rows, we get 3 rows per sync (1 sync + 2 ingest). This is fine -- command='sync' vs command='ingest:issues' distinguishes them.

### Connection sharing
run_sync and run_ingest already have access to a Connection. SyncRunRecorder::start takes &Connection.

### MetricsLayer handle
metrics_handle must be passed from main.rs through handle_sync_cmd/handle_ingest to run_sync/run_ingest. This requires adding a parameter. Alternative: use a thread-local or global. Prefer parameter passing for testability.

## Acceptance Criteria
- [ ] Every lore sync creates a sync_runs row with status transitioning running -> succeeded/failed
- [ ] Every lore ingest creates a sync_runs row
- [ ] metrics_json contains serialized Vec<StageTiming> on success
- [ ] Failed syncs record error message and partial metrics
- [ ] sync_runs.run_id matches run_id in log files and robot JSON
- [ ] total_items_processed and total_errors are populated
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (wire SyncRunRecorder + extract_timings in run_sync)
- src/cli/commands/ingest.rs (wire SyncRunRecorder in run_ingest)
- src/main.rs (pass metrics_handle to command handlers)

## TDD Loop
RED: test_sync_creates_run_record (integration: run sync, query sync_runs, assert row exists with metrics)
GREEN: Wire SyncRunRecorder into run_sync and run_ingest
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Database locked: SyncRunRecorder operations happen on the main connection. If a concurrent process holds the lock, the INSERT/UPDATE will wait (WAL mode) or error. Use existing lock handling.
- Partial failure: if ingest issues succeeds but ingest MRs fails, the sync recorder should fail() with partial metrics (stages from issues but not MRs).
- metrics_handle lifetime: must outlive the root span. Since it's an Arc clone, this is guaranteed.

Wired SyncRunRecorder into handle_sync_cmd and handle_ingest in main.rs


Implement issue document extraction

## Background
Issue documents are the simplest document type — a structured header + description text. The extractor queries the existing issues and issue_labels tables (populated by ingestion) and assembles a DocumentData struct. This is one of three entity-specific extractors (issue, MR, discussion) that feed the document regeneration pipeline.

## Approach
Implement `extract_issue_document()` in `src/documents/extractor.rs`:

```rust
/// Extract a searchable document from an issue.
/// Returns None if the issue has been deleted from the DB.
pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Main entity
SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
       i.created_at, i.updated_at, i.web_url,
       p.path_with_namespace, p.id AS project_id
FROM issues i
JOIN projects p ON p.id = i.project_id
WHERE i.id = ?

-- Labels
SELECT l.name FROM issue_labels il
JOIN labels l ON l.id = il.label_id
WHERE il.issue_id = ?
ORDER BY l.name
```

**Document format:**
```
[[Issue]] #234: Authentication redesign
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/issues/234
Labels: ["bug", "auth"]
State: opened
Author: @johndoe

--- Description ---

We need to modernize our authentication system...
```

**Implementation steps:**
1. Query issue row — if not found, return Ok(None)
2. Query labels via junction table
3. Format header with [[Issue]] prefix
4. Compute content_hash via compute_content_hash()
5. Compute labels_hash via compute_list_hash()
6. paths is always empty for issues (paths are only for DiffNote discussions)
7. Return DocumentData with all fields populated

## Acceptance Criteria
- [ ] Deleted issue (not in DB) returns Ok(None)
- [ ] Issue with no description: content_text has header only (no "--- Description ---" section)
- [ ] Issue with no labels: Labels line shows "[]"
- [ ] Issue with labels: Labels line shows sorted JSON array
- [ ] content_hash is SHA-256 of the full content_text
- [ ] labels_hash is SHA-256 of sorted label names joined by newline
- [ ] paths_hash is empty string hash (issues have no paths)
- [ ] project_id comes from the JOIN with projects table
- [ ] `cargo test extract_issue` passes

## Files
- `src/documents/extractor.rs` — implement `extract_issue_document()`

## TDD Loop
RED: Test in `#[cfg(test)] mod tests`:
- `test_issue_document_format` — verify header format matches PRD template
- `test_issue_not_found` — returns Ok(None) for nonexistent issue_id
- `test_issue_no_description` — no description section when description is NULL
- `test_issue_labels_sorted` — labels appear in alphabetical order
- `test_issue_hash_deterministic` — same issue produces same content_hash
GREEN: Implement extract_issue_document with SQL queries
VERIFY: `cargo test extract_issue`

## Edge Cases
- Issue with NULL description: skip "--- Description ---" section entirely
- Issue with empty string description: include section but with empty body
- Issue with very long description: no truncation here (hard cap applied by caller)
- Labels with special characters (quotes, commas): JSON array handles escaping

Implemented extract_issue_document() with SQL queries, PRD-compliant format, and 7 tests


OBSERV: Add #[instrument] spans to ingestion stages

## Background
Tracing spans on each sync stage create the hierarchy that (1) makes log lines filterable by stage, (2) Phase 3's MetricsLayer reads to build StageTiming trees, and (3) gives meaningful context in -vv stderr output.

## Approach
Add #[instrument] attributes or manual spans to these functions:

### src/ingestion/orchestrator.rs
1. ingest_project_issues_with_progress() (line ~110):
```rust
#[instrument(skip_all, fields(stage = "ingest_issues", project = %project_path))]
pub async fn ingest_project_issues_with_progress(...) -> Result<IngestProjectResult> {
```

2. The MR equivalent (ingest_project_mrs_with_progress or similar):
```rust
#[instrument(skip_all, fields(stage = "ingest_mrs", project = %project_path))]
```

3. Inside the issue ingest function, add child spans for sub-stages:
```rust
let _fetch_span = tracing::info_span!("fetch_pages", project = %project_path).entered();
// ... fetch logic
drop(_fetch_span);

let _disc_span = tracing::info_span!("sync_discussions", project = %project_path).entered();
// ... discussion sync logic
drop(_disc_span);
```

4. drain_resource_events() (line ~566):
```rust
let _span = tracing::info_span!("fetch_resource_events", project = %project_path).entered();
```

### src/documents/regenerator.rs
5. regenerate_dirty_documents() (line ~24):
```rust
#[instrument(skip_all, fields(stage = "generate_docs"))]
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult> {
```

### src/embedding/pipeline.rs
6. embed_documents() (line ~36):
```rust
#[instrument(skip_all, fields(stage = "embed"))]
pub async fn embed_documents(...) -> Result<EmbedResult> {
```

### Important: field declarations for Phase 3
The #[instrument] fields should include empty recording fields that Phase 3 (bd-16m8) will populate:
```rust
#[instrument(skip_all, fields(
    stage = "ingest_issues",
    project = %project_path,
    items_processed = tracing::field::Empty,
    items_skipped = tracing::field::Empty,
    errors = tracing::field::Empty,
))]
```

This declares the fields on the span so MetricsLayer can capture them when span.record() is called later.

## Acceptance Criteria
- [ ] JSON log lines show nested span context: sync > ingest_issues > fetch_pages
- [ ] Each stage span has a "stage" field with the stage name
- [ ] Per-project spans include "project" field
- [ ] Spans are visible in -vv stderr output as bracketed context
- [ ] Empty recording fields declared for items_processed, items_skipped, errors
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/ingestion/orchestrator.rs (spans on ingest functions and sub-stages)
- src/documents/regenerator.rs (span on regenerate_dirty_documents)
- src/embedding/pipeline.rs (span on embed_documents)

## TDD Loop
RED:
  - test_span_context_in_json_logs: mock sync, capture JSON, verify span chain
  - test_nested_span_chain: verify parent-child: sync > ingest_issues > fetch_pages
  - test_span_elapsed_on_close: create span, sleep 10ms, verify elapsed >= 10
GREEN: Add #[instrument] and manual spans to all stage functions
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- #[instrument] on async fn: uses tracing::Instrument trait automatically. Works with tokio.
- skip_all is essential: without it, #[instrument] tries to Debug-format all parameters, which may not implement Debug or may be expensive.
- Manual span drop: for sub-stages within a single function, use explicit drop(_span) to end the span before the next sub-stage starts. Otherwise spans overlap.
- tracing::field::Empty: declares a field that can be recorded later. If never recorded, it appears as empty/missing in output (not zero).

Added #[instrument] spans to ingest_project_issues_with_progress, ingest_project_merge_requests_with_progress, drain_resource_events, regenerate_dirty_documents, embed_documents


robot-docs: Add Ollama dependency discovery to manifest

## Background

Add Ollama dependency discovery to robot-docs so agents know which commands need Ollama and which work without it.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns RobotDocsData JSON
- RobotDocsData has fields: commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 documented commands in the manifest
- Ollama required for: embed, search --mode=semantic, search --mode=hybrid
- Not required for: all Phase B temporal commands (timeline, file-history, trace), lexical search, count, ingest, stats, etc.
- No dependencies field exists yet in RobotDocsData

## Approach

Add dependencies field to RobotDocsData struct and populate in handle_robot_docs():

```json
{
  "ollama": {
    "required_by": ["embed", "search --mode=semantic", "search --mode=hybrid"],
    "not_required_by": ["issues", "mrs", "search --mode=lexical", "timeline", "file-history", "trace", "count", "ingest", "stats", "sync", "doctor", "health"],
    "install": {"macos": "brew install ollama", "linux": "curl -fsSL https://ollama.ai/install.sh | sh"},
    "setup": "ollama pull nomic-embed-text",
    "note": "Lexical search and all temporal features work without Ollama."
  }
}
```

## Acceptance Criteria

- [ ] `lore robot-docs | jq '.data.dependencies.ollama'` returns structured info
- [ ] required_by and not_required_by lists are complete and accurate
- [ ] Phase B commands listed in not_required_by
- [ ] Install instructions for macos and linux
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- src/main.rs (update RobotDocsData struct + handle_robot_docs)

## TDD Loop

VERIFY: `lore robot-docs | jq '.data.dependencies.ollama.required_by'`

## Edge Cases

- Keep not_required_by up to date as new commands are added
- Phase B commands (timeline, file-history, trace) must be in not_required_by once they exist


WHO: Reviews mode query (query_reviews)

## Background

Reviews mode answers "What review patterns does person X have?" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).

## Approach

### Three queries:
1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs
2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id)
3. **Category extraction**: SQL-level prefix parsing + Rust normalization

### Category extraction SQL:
```sql
SELECT
    SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
    COUNT(*) AS cnt
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.author_username = ?1
  AND n.note_type = 'DiffNote' AND n.is_system = 0
  AND m.author_username != ?1
  AND ltrim(n.body) LIKE '**%**%'   -- only bodies with **prefix** pattern
  AND n.created_at >= ?2
  AND (?3 IS NULL OR n.project_id = ?3)
GROUP BY raw_prefix ORDER BY cnt DESC
```

Key: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).

### normalize_review_prefix() in Rust:
```rust
fn normalize_review_prefix(raw: &str) -> String {
    let s = raw.trim().trim_end_matches(':').trim().to_lowercase();
    // Strip parentheticals like "(non-blocking)"
    let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };
    // Merge nit/nitpick variants
    match s.as_str() {
        "nitpick" | "nit" => "nit".to_string(),
        other => other.to_string(),
    }
}
```

### HashMap merge for normalized categories, then sort by count DESC

### ReviewsResult struct:
```rust
pub struct ReviewsResult {
    pub username: String,
    pub total_diffnotes: u32,
    pub categorized_count: u32,
    pub mrs_reviewed: u32,
    pub categories: Vec<ReviewCategory>,
}
pub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }
```

No LIMIT needed — categories are naturally bounded (few distinct prefixes).

## Files

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

## TDD Loop

RED:
```
test_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2
test_normalize_review_prefix — "suggestion" "Suggestion:" "suggestion (non-blocking):" "Nitpick:" "nit (non-blocking):" "question" "TODO:"
```

GREEN: Implement query_reviews + normalize_review_prefix
VERIFY: `cargo test -- reviews`

## Acceptance Criteria

- [ ] test_reviews_query passes (total=3, categorized=2)
- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)
- [ ] Only counts DiffNotes on MRs user did NOT author
- [ ] Default since window: 6m

## Edge Cases

- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not "reviews"
- ltrim() handles leading whitespace before **prefix**
- Empty raw_prefix after normalization filtered out (!normalized.is_empty())
- Percentage calculated from categorized_count (not total_diffnotes)

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


Create migration 009_embeddings.sql

## Background
Migration 009 creates the embedding storage layer for Gate B. It introduces a sqlite-vec vec0 virtual table for vector search and an embedding_metadata table for tracking provenance per chunk. Unlike migrations 007-008, this migration REQUIRES sqlite-vec to be loaded before it can be applied. The migration runner in db.rs must load the sqlite-vec extension first.

## Approach
Create `migrations/009_embeddings.sql` per PRD Section 1.3.

**Tables:**
1. `embeddings` — vec0 virtual table with `embedding float[768]`
2. `embedding_metadata` — tracks per-chunk provenance with composite PK (document_id, chunk_index)
3. Orphan cleanup trigger: `documents_embeddings_ad` — deletes ALL chunk embeddings when a document is deleted using range deletion `[doc_id * 1000, (doc_id + 1) * 1000)`

**Critical: sqlite-vec loading:**
The migration runner in `src/core/db.rs` must load sqlite-vec BEFORE applying any migrations. This means adding extension loading to the `create_connection()` or `run_migrations()` function. sqlite-vec is loaded via:
```rust
conn.load_extension_enable()?;
conn.load_extension("vec0", None)?;  // or platform-specific path
conn.load_extension_disable()?;
```

Register migration 9 in `src/core/db.rs` MIGRATIONS array.

## Acceptance Criteria
- [ ] `migrations/009_embeddings.sql` file exists
- [ ] `embeddings` vec0 virtual table created with `embedding float[768]`
- [ ] `embedding_metadata` table has composite PK (document_id, chunk_index)
- [ ] `embedding_metadata.document_id` has FK to documents(id) ON DELETE CASCADE
- [ ] Error tracking fields: last_error, attempt_count, last_attempt_at
- [ ] Orphan cleanup trigger: deletes embeddings WHERE rowid in [doc_id*1000, (doc_id+1)*1000)
- [ ] Index on embedding_metadata(last_error) WHERE last_error IS NOT NULL
- [ ] Index on embedding_metadata(document_id)
- [ ] Schema version 9 recorded
- [ ] Migration runner loads sqlite-vec before applying migrations
- [ ] `cargo build` succeeds

## Files
- `migrations/009_embeddings.sql` — new file (copy exact SQL from PRD Section 1.3)
- `src/core/db.rs` — add migration 9 to MIGRATIONS array; add sqlite-vec extension loading

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails
GREEN: Create SQL file + add extension loading
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- sqlite-vec not installed: migration fails with clear error (not a silent skip)
- Migration applied without sqlite-vec loaded: `CREATE VIRTUAL TABLE` fails with "no such module: vec0"
- Documents deleted before embeddings: trigger fires but vec0 DELETE on empty range is safe
- vec0 doesn't support FK cascades: that's why we need the explicit trigger

Completed: migration 009_embeddings.sql with vec0 table, embedding_metadata with composite PK, orphan cleanup trigger, registered in db.rs


OBSERV: Enhance sync-status to show recent runs with metrics

## Background
sync_status currently queries sync_runs but always gets zero rows (nothing writes to the table). After bd-23a4 wires up SyncRunRecorder, rows will exist. This bead enhances the display to show recent runs with metrics.

## Approach
### src/cli/commands/sync_status.rs

1. Change get_last_sync_run() (line ~66) to get_recent_sync_runs() returning last N:
```rust
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
    let mut stmt = conn.prepare(
        "SELECT id, started_at, finished_at, status, command, error,
                run_id, total_items_processed, total_errors, metrics_json
         FROM sync_runs
         ORDER BY started_at DESC
         LIMIT ?1",
    )?;
    // ... map rows to SyncRunInfo
}
```

2. Extend SyncRunInfo to include new fields:
```rust
pub struct SyncRunInfo {
    pub id: i64,
    pub started_at: i64,
    pub finished_at: Option<i64>,
    pub status: String,
    pub command: String,
    pub error: Option<String>,
    pub run_id: Option<String>,          // NEW
    pub total_items_processed: i64,       // NEW
    pub total_errors: i64,                // NEW
    pub stages: Option<Vec<StageTiming>>, // NEW: parsed from metrics_json
}
```

3. Parse metrics_json into Vec<StageTiming>:
```rust
let stages: Option<Vec<StageTiming>> = row.get::<_, Option<String>>(9)?
    .and_then(|json| serde_json::from_str(&json).ok());
```

4. Interactive output (new format):
```
Recent sync runs:
  Run a1b2c3 | 2026-02-04 14:32 | 45.2s | 235 items | 1 error
  Run d4e5f6 | 2026-02-03 14:30 | 38.1s | 220 items | 0 errors
  Run g7h8i9 | 2026-02-02 14:29 | 42.7s | 228 items | 0 errors
```

5. Robot JSON output: runs array with stages parsed from metrics_json:
```json
{
    "ok": true,
    "data": {
        "runs": [{ "run_id": "...", "stages": [...] }],
        "cursors": [...],
        "summary": {...}
    }
}
```

6. Add --run <run_id> flag to sync-status subcommand for single-run detail view (shows full stage breakdown).

## Acceptance Criteria
- [ ] lore sync-status shows last 10 runs (not just 1) with run_id, duration, items, errors
- [ ] lore --robot sync-status JSON includes runs array with stages parsed from metrics_json
- [ ] lore sync-status --run a1b2c3 shows single run detail with full stage breakdown
- [ ] When no runs exist, shows appropriate "No sync runs recorded" message
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync_status.rs (rewrite query, extend structs, update display)

## TDD Loop
RED:
  - test_sync_status_shows_runs: insert 3 sync_runs rows, call print function, assert all 3 shown
  - test_sync_status_json_includes_stages: insert row with metrics_json, verify robot JSON has stages
  - test_sync_status_empty: no rows, verify graceful message
GREEN: Rewrite get_last_sync_run -> get_recent_sync_runs, extend SyncRunInfo, update output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- metrics_json is NULL (old rows or failed runs): stages field is null/empty in output
- metrics_json is malformed: serde_json::from_str fails silently (.ok()), stages is None
- Duration calculation: finished_at - started_at in ms. If finished_at is NULL (running), show "in progress"

Enhanced sync-status: shows last 10 runs with run_id, duration, items, errors, parsed stages; JSON includes full stages array


Add migration for dual-path and reviewer participation indexes

## Background
The restructured expert SQL (bd-1hoq) uses UNION ALL + dedup to match both old_path and new_path columns. Without indexes on old_path columns, these branches would force table scans. The reviewer_participation CTE joins notes -> discussions and needs index coverage on discussion_id.

## Approach
Create migration file migrations/021_scoring_indexes.sql. Add entry to MIGRATIONS array at db.rs:11-68 (currently 20 entries ending with 020). LATEST_SCHEMA_VERSION at db.rs:9 auto-increments via MIGRATIONS.len().

### Migration SQL:
```sql
-- Support old_path leg of matched_notes CTE
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
  ON notes(position_old_path, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;

-- Support old_path leg of matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
  ON mr_file_changes(old_path, project_id, merge_request_id)
  WHERE old_path IS NOT NULL;

-- Ensure new_path index parity for matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
  ON mr_file_changes(new_path, project_id, merge_request_id);

-- Support reviewer_participation CTE: notes -> discussions join
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
  ON notes(discussion_id, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0;
```

### MIGRATIONS array addition (db.rs, after line ~68):
```rust
("021", include_str!("../../migrations/021_scoring_indexes.sql")),
```

## Acceptance Criteria
- [ ] migrations/021_scoring_indexes.sql exists with 4 CREATE INDEX statements
- [ ] MIGRATIONS array has 21 entries
- [ ] LATEST_SCHEMA_VERSION == 21
- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)
- [ ] Existing indexes unaffected (017_who_indexes.sql untouched)

## Files
- migrations/021_scoring_indexes.sql (new file)
- src/core/db.rs (line ~68: add to MIGRATIONS array)

## Edge Cases
- Use CREATE INDEX IF NOT EXISTS (idempotent)
- Partial indexes with WHERE clauses keep size minimal
- position_old_path and old_path can be NULL (handled by WHERE clause in partial indexes)


[CP1] Epic: Issue Ingestion

Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.

Success Criteria:
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads
- Issue discussions fetched per-issue
- Cursor-based sync is resumable
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

Reference: docs/prd/checkpoint-1.md


Add Rust types to src/gitlab/types.rs for GitLab API responses.

## Types to Add

### GitLabIssue
- id: i64 (GitLab global ID)
- iid: i64 (project-scoped issue number)
- project_id: i64
- title: String
- description: Option<String>
- state: String ("opened" | "closed")
- created_at, updated_at: String (ISO 8601)
- closed_at: Option<String>
- author: GitLabAuthor
- labels: Vec<String> (array of label names - CP1 canonical)
- web_url: String
NOTE: labels_details intentionally NOT modeled - varies across GitLab versions

### GitLabAuthor
- id: i64
- username: String
- name: String

### GitLabDiscussion
- id: String (like "6a9c1750b37d...")
- individual_note: bool
- notes: Vec<GitLabNote>

### GitLabNote
- id: i64
- note_type: Option<String> ("DiscussionNote" | "DiffNote" | null)
- body: String
- author: GitLabAuthor
- created_at, updated_at: String (ISO 8601)
- system: bool
- resolvable: bool (default false)
- resolved: bool (default false)
- resolved_by: Option<GitLabAuthor>
- resolved_at: Option<String>
- position: Option<GitLabNotePosition>

### GitLabNotePosition
- old_path, new_path: Option<String>
- old_line, new_line: Option<i32>

Files: src/gitlab/types.rs
Tests: Test deserialization with fixtures
Done when: Types compile and deserialize sample API responses correctly