---
plan: true
title: "Work Item Status via GraphQL Enrichment"
status: iterating
iteration: 7
target_iterations: 8
beads_revision: 1
related_plans: []
created: 2026-02-10
updated: 2026-02-11
---

# Work Item Status via GraphQL Enrichment

> **Bead:** bd-2y79 | **Priority:** P1 | **Status:** Planning
> **Created:** 2026-02-10

## Problem

GitLab issues have native work item status (To do, In progress, Done, Won't do, Duplicate) but
this is only available via GraphQL — not the REST API we use for ingestion. Without this data,
`lore` cannot report or filter by workflow status, making it invisible to agents and humans.

---

## Acceptance Criteria

Each criterion is independently testable. Implementation is complete when ALL pass.

### AC-1: GraphQL Client (Unit)

- [ ] `GraphqlClient::query()` POSTs to `{base_url}/api/graphql` with `Content-Type: application/json`
- [ ] Request uses `Authorization: Bearer {token}` header (NOT `PRIVATE-TOKEN`)
- [ ] Request body is `{"query": "...", "variables": {...}}`
- [ ] Successful response: parses `data` field from JSON envelope
- [ ] Error response: if top-level `errors` array is non-empty AND `data` field is absent/null, returns `LoreError` with first error message
- [ ] Partial-data response: if `errors` array is non-empty BUT `data` field is present and non-null, returns `GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some("...") }` (does NOT fail the query — GraphQL spec permits `data` + `errors` coexistence for partial results)
- [ ] `GraphqlQueryResult` struct: `data: serde_json::Value`, `had_partial_errors: bool`, `first_partial_error: Option<String>` — enables callers to propagate partial-error metadata to observability surfaces
- [ ] Successful response (no errors): returns `GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None }`
- [ ] HTTP 401 → `LoreError::GitLabAuthFailed`
- [ ] HTTP 403 → `LoreError::GitLabAuthFailed` (forbidden treated as auth failure — no separate variant needed)
- [ ] HTTP 404 → `LoreError::GitLabNotFound`
- [ ] HTTP 429 → `LoreError::GitLabRateLimited` (respects `Retry-After` header — supports both delta-seconds and HTTP-date formats, falls back to 60s if unparseable)
- [ ] Network error → `LoreError::Other`

### AC-2: Status Types (Unit)

- [ ] `WorkItemStatus` struct has `name: String`, `category: Option<String>`, `color: Option<String>`, `icon_name: Option<String>`
- [ ] `category` stored as raw string from GraphQL (e.g., `"IN_PROGRESS"`, `"TO_DO"`) — no enum, no normalization
- [ ] `WorkItemStatus` deserializes from GraphQL JSON shape with `name`, `category`, `color`, `iconName`
- [ ] All fields except `name` are `Option` — absent fields deserialize to `None`
- [ ] Custom statuses (18.5+) with non-standard category values deserialize without error

### AC-3: Status Fetcher (Integration)

- [ ] `fetch_issue_statuses()` returns `FetchStatusResult` containing:
  - `statuses: HashMap<i64, WorkItemStatus>` keyed by issue IID (parsed from GraphQL's String to i64)
  - `all_fetched_iids: HashSet<i64>` — all IIDs seen in GraphQL response (for staleness clearing)
  - `unsupported_reason: Option<UnsupportedReason>` — set when enrichment was skipped due to 404/403 (enables orchestrator to distinguish "no statuses" from "feature unavailable")
  - `partial_error_count: usize` — count of pages that returned partial-data responses (data + errors)
  - `first_partial_error: Option<String>` — first partial-error message for diagnostic surfacing
- [ ] Paginates: follows `pageInfo.endCursor` + `hasNextPage` until all pages consumed
- [ ] Pagination guard: if `hasNextPage=true` but `endCursor` is `None` or unchanged, aborts pagination loop with a warning log and returns the partial result collected so far (prevents infinite loops from GraphQL cursor bugs)
- [ ] Adaptive page size: starts with `first=100`; on GraphQL complexity or timeout errors (detected via error message substring matching for `"complexity"` or `"timeout"`), retries the same cursor with halved page size (100→50→25→10); if page size 10 still fails, returns the error
- [ ] Query uses `$first: Int!` variable (not hardcoded `first: 100`) to support adaptive page sizing
- [ ] Query includes `__typename` in `widgets` selection; parser matches `__typename == "WorkItemWidgetStatus"` for deterministic widget identification (no heuristic try-deserialize)
- [ ] Non-status widgets are ignored deterministically via `__typename` check
- [ ] Issues with no status widget in `widgets` array → in `all_fetched_iids` but not in `statuses` map (no error)
- [ ] Issues with status widget but `status: null` → in `all_fetched_iids` but not in `statuses` map
- [ ] GraphQL 404 → returns `Ok(FetchStatusResult)` with empty collections, `unsupported_reason == Some(UnsupportedReason::GraphqlEndpointMissing)`, + warning log
- [ ] GraphQL 403 (`GitLabAuthFailed`) → returns `Ok(FetchStatusResult)` with empty collections, `unsupported_reason == Some(UnsupportedReason::AuthForbidden)`, + warning log

### AC-4: Migration 021 (Unit)

- [ ] Migration adds 5 nullable columns to `issues`: `status_name TEXT`, `status_category TEXT`, `status_color TEXT`, `status_icon_name TEXT`, `status_synced_at INTEGER` (ms epoch UTC — when enrichment last wrote/cleared this row's status)
- [ ] Adds compound index `idx_issues_project_status_name(project_id, status_name)` for `--status` filter performance
- [ ] `LATEST_SCHEMA_VERSION` becomes 21
- [ ] Existing issues retain all data (ALTER TABLE ADD COLUMN is non-destructive)
- [ ] In-memory DB test: after migration, `SELECT status_name, status_category, status_color, status_icon_name, status_synced_at FROM issues` succeeds
- [ ] NULL default: existing rows have NULL for all 5 new columns

### AC-5: Config Toggle (Unit)

- [ ] `SyncConfig` has `fetch_work_item_status: bool` field
- [ ] Default value: `true` (uses existing `default_true()` helper)
- [ ] JSON key: `"fetchWorkItemStatus"` (camelCase, matching convention)
- [ ] Existing config files without this key → defaults to `true` (no breakage)

### AC-6: Enrichment in Orchestrator (Integration)

- [ ] Enrichment runs after `ingest_issues()` completes, before discussion sync
- [ ] Runs on every sync (not gated by `--full`)
- [ ] Gated by `config.sync.fetch_work_item_status` — if `false`, skipped entirely
- [ ] Creates `GraphqlClient` via `client.graphql_client()` factory (token stays encapsulated)
- [ ] For each project: calls `fetch_issue_statuses()`, then UPDATEs matching `issues` rows
- [ ] Enrichment DB writes are transactional per project (all-or-nothing)
- [ ] Before applying updates, NULL out status fields for issues that were fetched but have no status widget (prevents stale status from lingering when status is removed)
- [ ] If enrichment fails mid-project, prior persisted statuses are unchanged (transaction rollback)
- [ ] UPDATE SQL: `SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=? WHERE project_id=? AND iid=?` (synced_at = current epoch ms)
- [ ] Clear SQL also sets `status_synced_at` to current epoch ms (records when we confirmed absence of status)
- [ ] Logs summary: `"Enriched {n} issues with work item status for {project}"` — includes `seen`, `enriched`, `cleared`, `without_widget` counts
- [ ] On any GraphQL error: logs warning, continues to next project (never fails the sync)
- [ ] If project path lookup fails (DB error or missing row), status enrichment is skipped for that project with warning log and `status_enrichment_error: "project_path_missing"` — does NOT fail the overall project pipeline
- [ ] `IngestProjectResult` gains `statuses_enriched: usize`, `statuses_cleared: usize`, `statuses_seen: usize`, `statuses_without_widget: usize`, `partial_error_count: usize`, `first_partial_error: Option<String>`, and `status_enrichment_error: Option<String>` (captures error message when enrichment fails for this project)
- [ ] Progress events: `StatusEnrichmentComplete { enriched, cleared }` and `StatusEnrichmentSkipped` (when config toggle is false)
- [ ] Enrichment log line includes `seen`, `enriched`, `cleared`, and `without_widget` counts for full observability

### AC-7: Show Issue Display (E2E)

**Human (`lore show issue 123`):**
- [ ] New line after "State": `Status:   In progress` (colored by `status_color` hex → nearest terminal color)
- [ ] Status line only shown when `status_name IS NOT NULL`
- [ ] Category shown in parens when available, lowercased: `Status:   In progress (in_progress)`

**Robot (`lore --robot show issue 123`):**
- [ ] JSON includes `status_name`, `status_category`, `status_color`, `status_icon_name`, `status_synced_at` fields
- [ ] Fields are `null` (not absent) when status not available
- [ ] `status_synced_at` is integer (ms epoch UTC) or `null` — enables freshness checks by consumers

### AC-8: List Issues Display (E2E)

**Human (`lore list issues`):**
- [ ] New "Status" column in table after "State" column
- [ ] Status name colored by `status_color` hex → nearest terminal color
- [ ] NULL status → empty cell (no placeholder text)

**Robot (`lore --robot list issues`):**
- [ ] JSON includes `status_name`, `status_category` fields on each issue
- [ ] `--fields` supports: `status_name`, `status_category`, `status_color`, `status_icon_name`, `status_synced_at`
- [ ] `--fields minimal` preset does NOT include status fields (keeps token count low)

### AC-9: List Issues Filter (E2E)

- [ ] `lore list issues --status "In progress"` → only issues where `status_name = 'In progress'`
- [ ] Filter uses case-insensitive matching (`COLLATE NOCASE`) for UX — `"in progress"` matches `"In progress"`
- [ ] `--status` is repeatable: `--status "In progress" --status "To do"` → issues matching ANY of the given statuses (OR semantics within `--status`, AND with other filters)
- [ ] Single `--status` produces `WHERE status_name = ? COLLATE NOCASE`; multiple produce `WHERE status_name IN (?, ?) COLLATE NOCASE`
- [ ] `--status` combined with other filters (e.g., `--state opened --status "To do"`) → AND logic
- [ ] `--status` with no matching issues → "No issues found."

### AC-10: Robot Sync Envelope (E2E)

- [ ] `lore --robot sync` JSON response includes per-project `status_enrichment` object: `{ "mode": "fetched|unsupported|skipped", "reason": null | "graphql_endpoint_missing" | "auth_forbidden", "seen": N, "enriched": N, "cleared": N, "without_widget": N, "partial_errors": N, "first_partial_error": null | "message", "error": null | "message" }`
- [ ] `mode: "fetched"` — enrichment ran normally (even if 0 statuses found)
- [ ] `mode: "unsupported"` — GraphQL returned 404 or 403; `reason` explains why; all counters are 0
- [ ] `mode: "skipped"` — config toggle is off; all other fields are 0/null
- [ ] When enrichment fails for a project: `error` contains the error message string, counters are 0, `mode` is `"fetched"` (it attempted)
- [ ] `partial_errors` > 0 indicates GraphQL returned partial-data responses (data + errors) — agents can use this to flag potentially incomplete status data
- [ ] `seen` counter enables agents to distinguish "project has 0 issues" from "project has 500 issues but 0 with status"
- [ ] Aggregate `status_enrichment_errors: N` in top-level sync summary for quick agent health checks

### AC-11: Compiler & Quality Gates

- [ ] `cargo check --all-targets` passes with zero errors
- [ ] `cargo clippy --all-targets -- -D warnings` passes (pedantic + nursery enabled)
- [ ] `cargo fmt --check` passes
- [ ] `cargo test` passes — all new + existing tests green

---

## GitLab API Constraints

### Tier Requirement

**Premium or Ultimate only.** The status widget (`WorkItemWidgetStatus`) lives entirely in
GitLab EE. On Free tier, the widget simply won't appear in the `widgets` array — no error,
just absent.

### Version Requirements

| GitLab Version | Status Support |
|---|---|
| < 17.11 | No status widget at all |
| 17.11 - 18.1 | Status widget (Experiment), `category` field missing |
| 18.2 - 18.3 | Status widget with feature flag (enabled by default) |
| 18.4+ | Generally available, `workItemAllowedStatuses` query added |
| 18.5+ | Custom statuses via Lifecycles |

### Authentication

GraphQL endpoint (`/api/graphql`) does **NOT** accept `PRIVATE-TOKEN` header.

Must use: `Authorization: Bearer <token>`

Same personal access token works — just a different header format. Requires `api` or `read_api` scope.

### Query Path

**Must use `workItems` resolver, NOT `project.issues`.** The legacy `issues` field returns the
old `Issue` type which does not include status widgets.

```graphql
query($projectPath: ID!, $after: String, $first: Int!) {
  project(fullPath: $projectPath) {
    workItems(types: [ISSUE], first: $first, after: $after) {
      nodes {
        iid
        state
        widgets {
          __typename
          ... on WorkItemWidgetStatus {
            status {
              name
              category
              color
              iconName
            }
          }
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}
```

### GraphQL Limits

| Limit | Value |
|---|---|
| Max complexity | 250 (authenticated) |
| Max page size | 100 nodes |
| Max query size | 10,000 chars |
| Request timeout | 30 seconds |

### Status Values

**System-defined statuses (default lifecycle):**

| ID | Name | Color | Category | Maps to State |
|----|------|-------|----------|---------------|
| 1 | To do | `#737278` (gray) | `to_do` | open |
| 2 | In progress | `#1f75cb` (blue) | `in_progress` | open |
| 3 | Done | `#108548` (green) | `done` | closed |
| 4 | Won't do | `#DD2B0E` (red) | `canceled` | closed |
| 5 | Duplicate | `#DD2B0E` (red) | `canceled` | closed |

**Known category values:** `TRIAGE`, `TO_DO`, `IN_PROGRESS`, `DONE`, `CANCELED`

Note: Organizations with Premium/Ultimate on 18.5+ can define up to 70 custom statuses per
namespace. Custom status names, IDs, and potentially category values will vary by instance.
We store category as a raw string (`Option<String>`) rather than an enum to avoid breaking
on unknown values from custom lifecycles or future GitLab releases.

### Status-State Synchronization

Setting a status in `done`/`canceled` category automatically closes the issue.
Setting a status in `triage`/`to_do`/`in_progress` automatically reopens it.
This is bidirectional — closing via REST sets status to `default_closed_status`.

---

## Implementation Detail

### File 1: `src/gitlab/graphql.rs` (NEW)

**Purpose:** Minimal GraphQL client and status-specific fetcher.

```rust
use std::collections::{HashMap, HashSet};

use reqwest::Client;
use serde::Deserialize;
use tracing::{debug, warn};

use crate::core::error::{LoreError, Result};

use super::types::WorkItemStatus;

// ─── GraphQL Client ──────────────────────────────────────────────────────────

pub struct GraphqlClient {
    http: Client,
    base_url: String,  // e.g. "https://gitlab.example.com"
    token: String,
}

/// Result of a GraphQL query — includes both data and partial-error metadata.
/// Partial errors occur when GraphQL returns both `data` and `errors` (per spec,
/// this means some fields resolved successfully while others failed).
pub struct GraphqlQueryResult {
    pub data: serde_json::Value,
    pub had_partial_errors: bool,
    pub first_partial_error: Option<String>,
}

impl GraphqlClient {
    pub fn new(base_url: &str, token: &str) -> Self {
        let base_url = base_url.trim_end_matches('/').to_string();
        let http = Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .expect("Failed to build HTTP client");
        Self {
            http,
            base_url,
            token: token.to_string(),
        }
    }

    /// POST a GraphQL query and return a `GraphqlQueryResult`.
    /// Returns Err if HTTP fails, JSON parse fails, or response has `errors` with no `data`.
    /// If response has both `errors` and `data`, returns `GraphqlQueryResult` with
    /// `had_partial_errors=true` and `first_partial_error` populated
    /// (GraphQL spec permits partial results with errors).
    pub async fn query(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> Result<GraphqlQueryResult> {
        let url = format!("{}/api/graphql", self.base_url);
        let body = serde_json::json!({
            "query": query,
            "variables": variables,
        });

        let response = self
            .http
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .map_err(|e| LoreError::Other(format!("GraphQL request failed: {e}")))?;

        let status = response.status();

        if status == reqwest::StatusCode::UNAUTHORIZED
            || status == reqwest::StatusCode::FORBIDDEN
        {
            return Err(LoreError::GitLabAuthFailed);
        }
        if status == reqwest::StatusCode::NOT_FOUND {
            return Err(LoreError::GitLabNotFound {
                resource: "GraphQL endpoint".to_string(),
            });
        }
        if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
            let retry_after = response
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|v| {
                    // Try delta-seconds first (e.g., "60"), then HTTP-date
                    v.parse::<u64>().ok().or_else(|| {
                        httpdate::parse_http_date(v)
                            .ok()
                            .and_then(|dt| dt.duration_since(std::time::SystemTime::now()).ok())
                            .map(|d| d.as_secs())
                    })
                })
                .unwrap_or(60);  // LoreError::GitLabRateLimited uses u64, not Option<u64>
            return Err(LoreError::GitLabRateLimited { retry_after });
        }
        if !status.is_success() {
            let body = response
                .text()
                .await
                .unwrap_or_else(|_| "unknown".to_string());
            return Err(LoreError::Other(format!(
                "GraphQL HTTP {}: {}",
                status, body
            )));
        }

        let json: serde_json::Value = response
            .json()
            .await
            .map_err(|e| LoreError::Other(format!("GraphQL response parse failed: {e}")))?;

        // Check for GraphQL-level errors
        let errors = json
            .get("errors")
            .and_then(|e| e.as_array())
            .filter(|arr| !arr.is_empty());

        let data = json.get("data").filter(|d| !d.is_null()).cloned();

        if let Some(err_array) = errors {
            let first_msg = err_array[0]
                .get("message")
                .and_then(|m| m.as_str())
                .unwrap_or("Unknown GraphQL error")
                .to_string();

            if let Some(data) = data {
                // Partial data with errors — return data with error metadata
                warn!(error = %first_msg, "GraphQL returned partial data with errors");
                return Ok(GraphqlQueryResult {
                    data,
                    had_partial_errors: true,
                    first_partial_error: Some(first_msg),
                });
            }
            // Errors only, no data
            return Err(LoreError::Other(format!("GraphQL error: {first_msg}")));
        }

        data.map(|d| GraphqlQueryResult {
            data: d,
            had_partial_errors: false,
            first_partial_error: None,
        })
        .ok_or_else(|| LoreError::Other("GraphQL response missing 'data' field".to_string()))
    }
}

// ─── Status Fetcher ──────────────────────────────────────────────────────────

const ISSUE_STATUS_QUERY: &str = r#"
query($projectPath: ID!, $after: String, $first: Int!) {
  project(fullPath: $projectPath) {
    workItems(types: [ISSUE], first: $first, after: $after) {
      nodes {
        iid
        widgets {
          __typename
          ... on WorkItemWidgetStatus {
            status {
              name
              category
              color
              iconName
            }
          }
        }
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}
"#;

/// Page sizes to try, in order, when adaptive fallback is triggered.
const PAGE_SIZES: &[u32] = &[100, 50, 25, 10];

/// Deserialization types for the GraphQL response shape.
/// These are private — only `WorkItemStatus` escapes via the return type.

#[derive(Deserialize)]
struct WorkItemsResponse {
    project: Option<ProjectNode>,
}

#[derive(Deserialize)]
struct ProjectNode {
    #[serde(rename = "workItems")]
    work_items: Option<WorkItemConnection>,
}

#[derive(Deserialize)]
struct WorkItemConnection {
    nodes: Vec<WorkItemNode>,
    #[serde(rename = "pageInfo")]
    page_info: PageInfo,
}

#[derive(Deserialize)]
struct WorkItemNode {
    iid: String,  // GraphQL returns iid as String
    widgets: Vec<serde_json::Value>,
}

#[derive(Deserialize)]
struct PageInfo {
    #[serde(rename = "endCursor")]
    end_cursor: Option<String>,
    #[serde(rename = "hasNextPage")]
    has_next_page: bool,
}

#[derive(Deserialize)]
struct StatusWidget {
    status: Option<WorkItemStatus>,
}

/// Reason why status enrichment was not available for a project.
/// Used in `FetchStatusResult` to distinguish
/// "no statuses found" from "feature unavailable."
#[derive(Debug, Clone)]
pub enum UnsupportedReason {
    /// GraphQL endpoint returned 404 (old GitLab, self-hosted with GraphQL disabled)
    GraphqlEndpointMissing,
    /// GraphQL endpoint returned 403 (insufficient permissions)
    AuthForbidden,
}

/// Result of fetching issue statuses — includes both the status map and the
/// set of all IIDs that were seen in the GraphQL response (with or without status).
/// `all_fetched_iids` is a `HashSet` for O(1) staleness lookups in the orchestrator
/// when NULLing out status fields for issues that no longer have a status widget.
///
/// `unsupported_reason` is `Some(...)` when enrichment was skipped due to 404/403 —
/// enables the orchestrator to distinguish "project has no statuses" from "feature
/// unavailable" for observability and robot sync output.
///
/// `partial_error_count` and `first_partial_error` track pages where GraphQL returned
/// both `data` and `errors` — enables agents to detect potentially incomplete data.
pub struct FetchStatusResult {
    pub statuses: HashMap<i64, WorkItemStatus>,
    pub all_fetched_iids: HashSet<i64>,
    pub unsupported_reason: Option<UnsupportedReason>,
    pub partial_error_count: usize,
    pub first_partial_error: Option<String>,
}

/// Returns true if a GraphQL error message suggests the query is too complex
/// or timed out — conditions where reducing page size may help.
fn is_complexity_or_timeout_error(msg: &str) -> bool {
    let lower = msg.to_ascii_lowercase();
    lower.contains("complexity") || lower.contains("timeout")
}

/// Fetch work item statuses for all issues in a project.
/// Returns a `FetchStatusResult` containing:
/// - `statuses`: map of IID (i64) → WorkItemStatus for issues that have a status widget
/// - `all_fetched_iids`: all IIDs seen in the GraphQL response as `HashSet<i64>` (for O(1) staleness clearing)
/// - `unsupported_reason`: `Some(...)` when enrichment was skipped due to 404/403
/// - `partial_error_count` / `first_partial_error`: partial-error tracking across pages
///
/// Paginates through all results. Uses adaptive page sizing: starts with 100,
/// falls back to 50→25→10 on complexity/timeout errors.
/// Includes a pagination guard: aborts if `hasNextPage=true` but cursor is
/// `None` or unchanged from the previous page (prevents infinite loops from GraphQL bugs).
///
/// On 404/403: returns Ok(FetchStatusResult) with empty maps + unsupported_reason + warning log.
/// On other errors: returns Err.
pub async fn fetch_issue_statuses(
    client: &GraphqlClient,
    project_path: &str,
) -> Result<FetchStatusResult> {
    let mut statuses = HashMap::new();
    let mut all_fetched_iids = HashSet::new();
    let mut cursor: Option<String> = None;
    let mut page_size_idx = 0;  // index into PAGE_SIZES
    let mut partial_error_count = 0usize;
    let mut first_partial_error: Option<String> = None;

    loop {
        let current_page_size = PAGE_SIZES[page_size_idx];
        let variables = serde_json::json!({
            "projectPath": project_path,
            "after": cursor,
            "first": current_page_size,
        });

        let query_result = match client.query(ISSUE_STATUS_QUERY, variables).await {
            Ok(result) => result,
            Err(LoreError::GitLabNotFound { .. }) => {
                warn!(
                    project = project_path,
                    "GraphQL endpoint not found — skipping status enrichment"
                );
                return Ok(FetchStatusResult {
                    statuses,
                    all_fetched_iids,
                    unsupported_reason: Some(UnsupportedReason::GraphqlEndpointMissing),
                    partial_error_count,
                    first_partial_error,
                });
            }
            Err(LoreError::GitLabAuthFailed) => {
                warn!(
                    project = project_path,
                    "GraphQL auth failed — skipping status enrichment"
                );
                return Ok(FetchStatusResult {
                    statuses,
                    all_fetched_iids,
                    unsupported_reason: Some(UnsupportedReason::AuthForbidden),
                    partial_error_count,
                    first_partial_error,
                });
            }
            Err(LoreError::Other(ref msg)) if is_complexity_or_timeout_error(msg) => {
                // Adaptive page size: try smaller page
                if page_size_idx + 1 < PAGE_SIZES.len() {
                    let old_size = PAGE_SIZES[page_size_idx];
                    page_size_idx += 1;
                    let new_size = PAGE_SIZES[page_size_idx];
                    warn!(
                        project = project_path,
                        old_size,
                        new_size,
                        "GraphQL complexity/timeout error — reducing page size and retrying"
                    );
                    continue;  // retry same cursor with smaller page
                }
                // Smallest page size still failed
                return Err(LoreError::Other(msg.clone()));
            }
            Err(e) => return Err(e),
        };

        // Track partial-error metadata
        if query_result.had_partial_errors {
            partial_error_count += 1;
            if first_partial_error.is_none() {
                first_partial_error = query_result.first_partial_error.clone();
            }
        }

        let response: WorkItemsResponse = serde_json::from_value(query_result.data)
            .map_err(|e| LoreError::Other(format!("Failed to parse GraphQL response: {e}")))?;

        let connection = match response
            .project
            .and_then(|p| p.work_items)
        {
            Some(c) => c,
            None => {
                debug!(
                    project = project_path,
                    "No workItems in GraphQL response"
                );
                break;
            }
        };

        for node in &connection.nodes {
            if let Ok(iid) = node.iid.parse::<i64>() {
                all_fetched_iids.insert(iid);

                // Find the status widget via __typename for deterministic matching
                for widget_value in &node.widgets {
                    let is_status_widget = widget_value
                        .get("__typename")
                        .and_then(|t| t.as_str())
                        == Some("WorkItemWidgetStatus");

                    if is_status_widget {
                        if let Ok(sw) = serde_json::from_value::<StatusWidget>(widget_value.clone())
                            && let Some(status) = sw.status
                        {
                            statuses.insert(iid, status);
                        }
                    }
                }
            }
        }

        // After a successful page, reset page size back to max for next page
        // (the complexity issue may be cursor-position-specific)
        page_size_idx = 0;

        if connection.page_info.has_next_page {
            let new_cursor = connection.page_info.end_cursor;
            // Pagination guard: abort if cursor is None or unchanged (prevents infinite loops)
            if new_cursor.is_none() || new_cursor == cursor {
                warn!(
                    project = project_path,
                    pages_fetched = all_fetched_iids.len(),
                    "Pagination cursor stalled (hasNextPage=true but cursor unchanged) — returning partial result"
                );
                break;
            }
            cursor = new_cursor;
        } else {
            break;
        }
    }

    debug!(
        project = project_path,
        count = statuses.len(),
        total_fetched = all_fetched_iids.len(),
        partial_error_count,
        "Fetched issue statuses via GraphQL"
    );

    Ok(FetchStatusResult {
        statuses,
        all_fetched_iids,
        unsupported_reason: None,
        partial_error_count,
        first_partial_error,
    })
}

/// Map RGB to nearest ANSI 256-color index using the 6x6x6 color cube (indices 16-231).
/// NOTE: clippy::items_after_test_module — this MUST be placed BEFORE the test module.
pub fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
    let ri = ((u16::from(r) * 5 + 127) / 255) as u8;
    let gi = ((u16::from(g) * 5 + 127) / 255) as u8;
    let bi = ((u16::from(b) * 5 + 127) / 255) as u8;
    16 + 36 * ri + 6 * gi + bi
}

#[cfg(test)]
mod tests {
    use super::*;
    // Tests use wiremock or similar mock server
    // See TDD Plan section for test specifications
}
```

### File 2: `src/gitlab/mod.rs` (MODIFY)

**Existing code (14 lines):**
```rust
pub mod client;
pub mod transformers;
pub mod types;
```

**Add after `pub mod types;`:**
```rust
pub mod graphql;
```

**Add to `pub use types::{...}` list:**
```rust
WorkItemStatus,
```

### File 3: `src/gitlab/types.rs` (MODIFY)

**Add at end of file (after `GitLabMergeRequest` struct, before any `#[cfg(test)]`):**

```rust
/// Work item status from GitLab GraphQL API.
/// Stored in the `issues` table columns: status_name, status_category, status_color, status_icon_name.
///
/// `category` is stored as a raw string from GraphQL (e.g., "IN_PROGRESS", "TO_DO", "DONE").
/// No enum — custom statuses on GitLab 18.5+ can have arbitrary category values, and even
/// system-defined categories may change across GitLab versions. Storing the raw string avoids
/// serde deserialization failures on unknown values.
#[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>,
}
```

### File 4: `src/core/db.rs` (MODIFY)

**Existing pattern — each migration is a tuple of `(&str, &str)` loaded via `include_str!`:**
```rust
const MIGRATIONS: &[(&str, &str)] = &[
    ("001", include_str!("../../migrations/001_initial.sql")),
    // ... 002 through 020 ...
    ("020", include_str!("../../migrations/020_mr_diffs_watermark.sql")),
];
```

**Add as the 21st entry at the end of the `MIGRATIONS` array:**
```rust
    ("021", include_str!("../../migrations/021_work_item_status.sql")),
```

`LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32` — automatically becomes 21.

### File 5: `src/core/config.rs` (MODIFY)

**In `SyncConfig` struct, add after `fetch_mr_file_changes`:**
```rust
#[serde(rename = "fetchWorkItemStatus", default = "default_true")]
pub fetch_work_item_status: bool,
```

**In `impl Default for SyncConfig`, add after `fetch_mr_file_changes: true`:**
```rust
fetch_work_item_status: true,
```

### File 6: `src/ingestion/orchestrator.rs` (MODIFY)

**Existing `ingest_project_issues_with_progress` flow (simplified):**
```
Phase 1: ingest_issues()
Phase 2: sync_discussions()     ← discussions for changed issues
Phase 3: drain_resource_events() ← if config.sync.fetch_resource_events
Phase 4: extract_refs_from_state_events()
```

**Insert new Phase 1.5 between issue ingestion and discussion sync.**

The orchestrator function receives a `&GitLabClient`. Use the new `client.graphql_client()`
factory (File 13) to get a ready-to-use GraphQL client without exposing the raw token.
The function has `project_id: i64` but needs `path_with_namespace` for GraphQL — look it up from DB.
**The path lookup uses `.optional()?` to make failure non-fatal** — if the project path is
missing or the query fails, enrichment is skipped with a structured error rather than
failing the entire project pipeline:

```rust
// ── Phase 1.5: GraphQL Status Enrichment ────────────────────────────────

if config.sync.fetch_work_item_status && !signal.is_cancelled() {
    // Get project path for GraphQL query (orchestrator only has project_id).
    // Non-fatal: if path is missing, skip enrichment with structured error.
    let project_path: Option<String> = conn
        .query_row(
            "SELECT path_with_namespace FROM projects WHERE id = ?1",
            [project_id],
            |r| r.get(0),
        )
        .optional()?;

    let Some(project_path) = project_path else {
        warn!(project_id, "Project path not found — skipping status enrichment");
        result.status_enrichment_error = Some("project_path_missing".to_string());
        result.status_enrichment_mode = "fetched".to_string();  // attempted but unavailable
        emit(ProgressEvent::StatusEnrichmentComplete { enriched: 0, cleared: 0 });
        // Fall through to discussion sync — enrichment failure is non-fatal
    };

    if let Some(ref project_path) = project_path {
        let graphql_client = client.graphql_client();  // factory keeps token encapsulated

        match crate::gitlab::graphql::fetch_issue_statuses(
            &graphql_client,
            project_path,
        )
        .await
        {
            Ok(fetch_result) => {
                // Record unsupported reason for robot sync output
                result.status_enrichment_mode = match &fetch_result.unsupported_reason {
                    Some(crate::gitlab::graphql::UnsupportedReason::GraphqlEndpointMissing) => {
                        result.status_unsupported_reason = Some("graphql_endpoint_missing".to_string());
                        "unsupported".to_string()
                    }
                    Some(crate::gitlab::graphql::UnsupportedReason::AuthForbidden) => {
                        result.status_unsupported_reason = Some("auth_forbidden".to_string());
                        "unsupported".to_string()
                    }
                    None => "fetched".to_string(),
                };

                // Coverage telemetry
                result.statuses_seen = fetch_result.all_fetched_iids.len();
                result.partial_error_count = fetch_result.partial_error_count;
                result.first_partial_error = fetch_result.first_partial_error.clone();

                let now_ms = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_millis() as i64;
                let (enriched, cleared) = enrich_issue_statuses_txn(
                    conn, project_id, &fetch_result.statuses, &fetch_result.all_fetched_iids, now_ms,
                )?;
                result.statuses_enriched = enriched;
                result.statuses_cleared = cleared;
                result.statuses_without_widget = result.statuses_seen
                    .saturating_sub(enriched);
                info!(
                    project = %project_path,
                    seen = result.statuses_seen,
                    enriched,
                    cleared,
                    without_widget = result.statuses_without_widget,
                    partial_errors = result.partial_error_count,
                    "Issue status enrichment complete"
                );
            }
            Err(e) => {
                let msg = format!("{e}");
                warn!(
                    project = %project_path,
                    error = %msg,
                    "GraphQL status enrichment failed — continuing without status data"
                );
                result.status_enrichment_error = Some(msg);
                result.status_enrichment_mode = "fetched".to_string();  // it attempted
            }
        }

        emit(ProgressEvent::StatusEnrichmentComplete {
            enriched: result.statuses_enriched,
            cleared: result.statuses_cleared,
        });
    }
} else {
    result.status_enrichment_mode = "skipped".to_string();
    emit(ProgressEvent::StatusEnrichmentSkipped);
}
```

**New helper function in `orchestrator.rs` — transactional with staleness clearing:**

```rust
/// Apply status enrichment within a transaction. Two phases:
/// 1. NULL out status fields for issues that were fetched but have no status widget
///    (prevents stale status from lingering when a status is removed in GitLab)
/// 2. Apply new status values for issues that do have a status widget
///
/// Both phases write `status_synced_at` to record when enrichment last touched each row.
/// If anything fails, the entire transaction rolls back — no partial updates.
fn enrich_issue_statuses_txn(
    conn: &Connection,
    project_id: i64,
    statuses: &HashMap<i64, crate::gitlab::types::WorkItemStatus>,
    all_fetched_iids: &HashSet<i64>,
    now_ms: i64,
) -> Result<(usize, usize)> {
    let tx = conn.unchecked_transaction()?;

    // Phase 1: Clear stale statuses for fetched issues that no longer have a status widget
    let mut clear_stmt = tx.prepare_cached(
        "UPDATE issues
         SET status_name = NULL, status_category = NULL, status_color = NULL,
             status_icon_name = NULL, status_synced_at = ?3
         WHERE project_id = ?1 AND iid = ?2
           AND status_name IS NOT NULL",
    )?;
    let mut cleared = 0usize;
    for iid in all_fetched_iids {
        if !statuses.contains_key(iid) {
            let rows = clear_stmt.execute(rusqlite::params![project_id, iid, now_ms])?;
            if rows > 0 {
                cleared += 1;
            }
        }
    }

    // Phase 2: Apply new status values
    let mut update_stmt = tx.prepare_cached(
        "UPDATE issues
         SET status_name = ?1, status_category = ?2, status_color = ?3,
             status_icon_name = ?4, status_synced_at = ?5
         WHERE project_id = ?6 AND iid = ?7",
    )?;

    let mut enriched = 0;
    for (iid, status) in statuses {
        let rows = update_stmt.execute(rusqlite::params![
            &status.name,
            &status.category,
            &status.color,
            &status.icon_name,
            now_ms,
            project_id,
            iid,
        ])?;
        if rows > 0 {
            enriched += 1;
        }
    }

    tx.commit()?;
    Ok((enriched, cleared))
}
```

**Modify `IngestProjectResult` — add fields:**
```rust
pub statuses_enriched: usize,
pub statuses_cleared: usize,
pub statuses_seen: usize,                     // total IIDs in GraphQL response
pub statuses_without_widget: usize,           // seen - enriched (coverage metric)
pub partial_error_count: usize,               // pages with partial-data responses
pub first_partial_error: Option<String>,       // first partial-error message
pub status_enrichment_error: Option<String>,
pub status_enrichment_mode: String,            // "fetched" | "unsupported" | "skipped"
pub status_unsupported_reason: Option<String>, // "graphql_endpoint_missing" | "auth_forbidden"
```

**Modify `ProgressEvent` enum — add variants:**
```rust
StatusEnrichmentComplete { enriched: usize, cleared: usize },
StatusEnrichmentSkipped,  // emitted when config.sync.fetch_work_item_status is false
```

### File 7: `src/cli/commands/show.rs` (MODIFY)

**Modify `IssueRow` (private struct in show.rs) — add 5 fields:**
```rust
status_name: Option<String>,
status_category: Option<String>,
status_color: Option<String>,
status_icon_name: Option<String>,
status_synced_at: Option<i64>,
```

**Modify BOTH `find_issue` SQL queries (with and without project filter) — add 5 columns to SELECT:**

Note: `find_issue()` has two separate SQL strings — one for when `project_filter` is `Some` and
one for `None`. Both must be updated identically.

```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,
       i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at
```

Column indices: `status_name=12, status_category=13, status_color=14, status_icon_name=15, status_synced_at=16`

**Modify `find_issue` row mapping — add after `milestone_title: row.get(11)?`:**
```rust
status_name: row.get(12)?,
status_category: row.get(13)?,
status_color: row.get(14)?,
status_icon_name: row.get(15)?,
status_synced_at: row.get(16)?,
```

**Modify `IssueDetail` (public struct) — add 5 fields:**
```rust
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
```

**Modify `run_show_issue` return — add fields from `issue` row:**
```rust
status_name: issue.status_name,
status_category: issue.status_category,
status_color: issue.status_color,
status_icon_name: issue.status_icon_name,
status_synced_at: issue.status_synced_at,
```

**Modify `print_show_issue` — add after the "State:" line (currently ~line 604):**
```rust
if let Some(status) = &issue.status_name {
    let status_display = if let Some(cat) = &issue.status_category {
        format!("{status} ({})", cat.to_ascii_lowercase())
    } else {
        status.clone()
    };
    println!(
        "Status:   {}",
        style_with_hex(&status_display, issue.status_color.as_deref())
    );
}
```

**New helper function for hex color → terminal color:**
```rust
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
    let Some(hex) = hex else {
        return style(text);
    };
    let hex = hex.trim_start_matches('#');
    // NOTE: clippy::collapsible_if — must combine conditions
    if hex.len() == 6
        && let (Ok(r), Ok(g), Ok(b)) = (
            u8::from_str_radix(&hex[0..2], 16),
            u8::from_str_radix(&hex[2..4], 16),
            u8::from_str_radix(&hex[4..6], 16),
        )
    {
        return style(text).color256(crate::gitlab::graphql::ansi256_from_rgb(r, g, b));
    }
    style(text)
}
```

**Modify `IssueDetailJson` — add 5 fields:**
```rust
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
```

**Modify `From<&IssueDetail> for IssueDetailJson` — add after existing field mappings:**
```rust
status_name: d.status_name.clone(),
status_category: d.status_category.clone(),
status_color: d.status_color.clone(),
status_icon_name: d.status_icon_name.clone(),
status_synced_at: d.status_synced_at,
```

### File 8: `src/cli/commands/list.rs` (MODIFY)

**Modify `IssueListRow` — add 5 fields:**
```rust
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
```

**Modify `IssueListRowJson` — add 5 fields (all for robot mode):**
```rust
pub status_name: Option<String>,
pub status_category: Option<String>,
pub status_color: Option<String>,
pub status_icon_name: Option<String>,
pub status_synced_at: Option<i64>,
```

**Modify `From<&IssueListRow> for IssueListRowJson` — add after existing field mappings:**
```rust
status_name: r.status_name.clone(),
status_category: r.status_category.clone(),
status_color: r.status_color.clone(),
status_icon_name: r.status_icon_name.clone(),
status_synced_at: r.status_synced_at,
```

**Modify `query_issues` SELECT — add 5 columns after `unresolved_count` subquery:**
```sql
i.status_name,
i.status_category,
i.status_color,
i.status_icon_name,
i.status_synced_at
```

**Modify `query_issues` row mapping — add after `unresolved_count: row.get(11)?`:**

The existing `query_issues` SELECT has 12 columns (indices 0-11). The 5 new status columns
append as indices 12-16:

```rust
status_name: row.get(12)?,
status_category: row.get(13)?,
status_color: row.get(14)?,
status_icon_name: row.get(15)?,
status_synced_at: row.get(16)?,
```

**Modify `ListFilters` — add status filter:**
```rust
pub statuses: &'a [String],
```

**Modify `query_issues` WHERE clause builder — add after `has_due_date` block:**
```rust
if !filters.statuses.is_empty() {
    if filters.statuses.len() == 1 {
        where_clauses.push("i.status_name = ? COLLATE NOCASE");
        params.push(Box::new(filters.statuses[0].clone()));
    } else {
        // Build IN clause: "i.status_name IN (?, ?, ?) COLLATE NOCASE"
        let placeholders = vec!["?"; filters.statuses.len()].join(", ");
        where_clauses.push(format!("i.status_name IN ({placeholders}) COLLATE NOCASE"));
        for s in filters.statuses {
            params.push(Box::new(s.clone()));
        }
    }
}
```

**Modify `print_list_issues` table — add "Status" column header between "State" and "Assignee":**

Current column order: IID, Title, State, Assignee, Labels, Disc, Updated
New column order: IID, Title, State, **Status**, Assignee, Labels, Disc, Updated

```rust
Cell::new("Status").add_attribute(Attribute::Bold),
```

**Modify `print_list_issues` row — add status cell after state cell (before assignee cell):**
```rust
let status_cell = match &issue.status_name {
    Some(status) => colored_cell_hex(status, issue.status_color.as_deref()),
    None => Cell::new(""),
};
```

**New helper function for hex → `comfy_table::Color`:**
```rust
fn colored_cell_hex(content: impl std::fmt::Display, hex: Option<&str>) -> Cell {
    let Some(hex) = hex else {
        return Cell::new(content);
    };
    if !console::colors_enabled() {
        return Cell::new(content);
    }
    let hex = hex.trim_start_matches('#');
    // NOTE: clippy::collapsible_if — must combine conditions
    if hex.len() == 6
        && let (Ok(r), Ok(g), Ok(b)) = (
            u8::from_str_radix(&hex[0..2], 16),
            u8::from_str_radix(&hex[2..4], 16),
            u8::from_str_radix(&hex[4..6], 16),
        )
    {
        return Cell::new(content).fg(Color::Rgb { r, g, b });
    }
    Cell::new(content)
}
```

### File 9: `src/cli/mod.rs` (MODIFY)

**In `IssuesArgs` struct, add `--status` flag (after `--milestone` or similar filter flags):**
```rust
/// Filter by work item status name (e.g., "In progress"). Repeatable for OR semantics.
#[arg(long, help_heading = "Filters")]
pub status: Vec<String>,
```

### File 10: `src/main.rs` (MODIFY)

**In `handle_issues` function (~line 695), add `statuses` to `ListFilters` construction:**
```rust
let filters = ListFilters {
    // ... existing fields ...
    statuses: &args.status,
};
```

**In legacy `List` command handler (~line 2421), also add `statuses: &[]` to `ListFilters`:**
```rust
let filters = ListFilters {
    // ... existing fields ...
    statuses: &[],  // legacy command has no --status flag
};
```

### File 11: `src/cli/autocorrect.rs` (MODIFY)

**In `COMMAND_FLAGS` array (~line 52), add `"--status"` to the `"issues"` entry:**
```rust
(
    "issues",
    &[
        // ... existing flags ...
        "--has-due",
        "--no-has-due",
        "--status",       // <-- ADD THIS
        "--sort",
        // ...
    ],
),
```

The `registry_covers_command_flags` test validates all clap flags are registered here — it will
fail if `--status` is missing.

### File 12: `src/cli/commands/ingest.rs` (MODIFY)

**In the `ProgressEvent` match within the progress callback, add new arms:**
```rust
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
    // handle progress display
}
ProgressEvent::StatusEnrichmentSkipped => {
    // handle skipped display (config toggle off)
}
```

The existing match is exhaustive — adding new variants to the enum without adding
these arms will cause a compile error.

### File 13: `src/gitlab/client.rs` (MODIFY)

**Add `GraphqlClient` factory — keeps token encapsulated (no raw accessor):**
```rust
/// Create a GraphQL client using the same base URL and token as this REST client.
/// The token is not exposed — only a ready-to-use GraphQL client is returned.
pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {
    crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)
}
```

### File 14: `migrations/021_work_item_status.sql` (NEW)

```sql
ALTER TABLE issues ADD COLUMN status_name TEXT;
ALTER TABLE issues ADD COLUMN status_category TEXT;
ALTER TABLE issues ADD COLUMN status_color TEXT;
ALTER TABLE issues ADD COLUMN status_icon_name TEXT;
ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;

CREATE INDEX IF NOT EXISTS idx_issues_project_status_name
  ON issues(project_id, status_name);
```

---

## Migration Numbering

Current state: migrations 001-020 exist on disk and in `MIGRATIONS` array.
This feature uses **migration 021**.

## Files Changed (Summary)

| File | Change | Lines (est) |
|---|---|---|
| `migrations/021_work_item_status.sql` | **NEW** — 5 ALTER TABLE ADD COLUMN + 1 index | 8 |
| `src/gitlab/graphql.rs` | **NEW** — GraphQL client (with `GraphqlQueryResult` struct, partial-error metadata, HTTP-date Retry-After) + status fetcher + `FetchStatusResult` (HashSet, partial-error counters) + `UnsupportedReason` enum + adaptive page sizing + pagination guard + `ansi256_from_rgb` + `__typename` matching | ~380 |
| `src/gitlab/mod.rs` | Add `pub mod graphql;` + re-exports | +3 |
| `src/gitlab/types.rs` | Add `WorkItemStatus` struct (no enum — category is `Option<String>`) | +15 |
| `src/gitlab/client.rs` | Add `pub fn graphql_client()` factory | +5 |
| `src/core/db.rs` | Add migration 021 to `MIGRATIONS` array | +5 |
| `src/core/config.rs` | Add `fetch_work_item_status` to `SyncConfig` + Default | +4 |
| `src/ingestion/orchestrator.rs` | Enrichment step (non-fatal path lookup) + `enrich_issue_statuses_txn()` (with `now_ms` param, returns enriched+cleared) + coverage telemetry (`seen`, `without_widget`, `partial_error_count`, `first_partial_error`) + `status_enrichment_error` + `status_enrichment_mode` + `status_unsupported_reason` on result + progress fields | +125 |
| `src/cli/commands/show.rs` | 5 fields on `IssueRow`/`IssueDetail`/`IssueDetailJson`, display, hex color helper | +45 |
| `src/cli/commands/list.rs` | 5 fields on row types, SQL, column, repeatable `--status` filter with IN clause, hex color helper | +50 |
| `src/cli/commands/ingest.rs` | Add `StatusEnrichmentComplete` + `StatusEnrichmentSkipped` match arms to progress callback | +6 |
| `src/cli/mod.rs` | `--status` flag on `IssuesArgs` (`Vec<String>` for repeatable) | +3 |
| `src/cli/autocorrect.rs` | Add `"--status"` to `COMMAND_FLAGS` issues entry | +1 |
| `src/main.rs` | Wire `statuses` into both `ListFilters` constructions | +2 |

**Total: ~650 lines new/modified across 14 files (2 new, 12 modified)**

## TDD Plan

### RED Phase — Tests to Write First

1. **`test_graphql_query_success`** — mock HTTP server returns `{"data":{"foo":"bar"}}` → `Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: false, first_partial_error: None })`
2. **`test_graphql_query_with_errors_no_data`** — mock returns `{"errors":[{"message":"bad"}]}` (no `data` field) → `Err(LoreError::Other("GraphQL error: bad"))`
3. **`test_graphql_auth_uses_bearer`** — mock asserts request has `Authorization: Bearer tok123` header
4. **`test_graphql_401_maps_to_auth_failed`** — mock returns 401 → `Err(LoreError::GitLabAuthFailed)`
5. **`test_graphql_403_maps_to_auth_failed`** — mock returns 403 → `Err(LoreError::GitLabAuthFailed)`
6. **`test_graphql_404_maps_to_not_found`** — mock returns 404 → `Err(LoreError::GitLabNotFound)`
7. **`test_work_item_status_deserialize`** — parse `{"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}` → category is `Some("IN_PROGRESS")`
8. **`test_work_item_status_optional_fields`** — parse `{"name":"To do"}` → category/color/icon_name are None
9. **`test_work_item_status_unknown_category`** — parse `{"name":"Custom","category":"SOME_FUTURE_VALUE"}` → category is `Some("SOME_FUTURE_VALUE")` (no deserialization error)
10. **`test_work_item_status_null_category`** — parse `{"name":"In progress","category":null}` → category is None
11. **`test_fetch_statuses_pagination`** — mock returns 2 pages → all statuses in map, all_fetched_iids includes all IIDs
12. **`test_fetch_statuses_no_status_widget`** — mock returns widgets without StatusWidget → empty statuses map, all_fetched_iids still populated
13. **`test_fetch_statuses_404_graceful`** — mock returns 404 → `Ok(FetchStatusResult)` with empty maps, `unsupported_reason == Some(GraphqlEndpointMissing)`
14. **`test_fetch_statuses_403_graceful`** — mock returns 403 → `Ok(FetchStatusResult)` with empty maps, `unsupported_reason == Some(AuthForbidden)`
15. **`test_migration_021_adds_columns`** — in-memory DB: `PRAGMA table_info(issues)` includes 5 new columns (including `status_synced_at`)
16. **`test_migration_021_adds_index`** — in-memory DB: `PRAGMA index_list(issues)` includes `idx_issues_project_status_name`
17. **`test_enrich_issue_statuses_txn`** — insert issue, call `enrich_issue_statuses_txn()`, verify 4 status columns populated + `status_synced_at` is set to `now_ms`
18. **`test_enrich_skips_unknown_iids`** — status map has IID not in DB → no error, returns 0
19. **`test_enrich_clears_removed_status`** — issue previously had status, now in `all_fetched_iids` but not in `statuses` → status fields NULLed out, `status_synced_at` updated to `now_ms` (not NULLed — confirms we checked this row)
20. **`test_enrich_transaction_rolls_back_on_failure`** — simulate failure mid-enrichment → no partial updates, prior status values intact
21. **`test_list_filter_by_status`** — insert 2 issues with different statuses, filter returns correct one
22. **`test_list_filter_by_status_case_insensitive`** — `--status "in progress"` matches `"In progress"` via COLLATE NOCASE
23. **`test_config_fetch_work_item_status_default_true`** — `SyncConfig::default().fetch_work_item_status == true`
24. **`test_config_deserialize_without_key`** — JSON without `fetchWorkItemStatus` → defaults to `true`
25. **`test_ansi256_from_rgb`** — known conversions: `(0,0,0)→16`, `(255,255,255)→231`, `(31,117,203)→~68`
26. **`test_enrich_idempotent_across_two_runs`** — run `enrich_issue_statuses_txn()` twice with same data → columns unchanged, `enriched` count same both times
27. **`test_typename_matching_ignores_non_status_widgets`** — widgets array with `__typename: "WorkItemWidgetDescription"` → not parsed as status, no error
28. **`test_retry_after_http_date_format`** — mock returns 429 with `Retry-After: Wed, 11 Feb 2026 01:00:00 GMT` → parses to delta seconds from now
29. **`test_retry_after_invalid_falls_back_to_60`** — mock returns 429 with `Retry-After: garbage` → falls back to 60
30. **`test_enrich_sets_synced_at_on_clear`** — issue with status cleared → `status_synced_at` is `now_ms` (not NULL)
31. **`test_enrichment_error_captured_in_result`** — simulate GraphQL error → `IngestProjectResult.status_enrichment_error` contains error message string
32. **`test_robot_sync_includes_status_enrichment`** — robot sync output JSON includes per-project `status_enrichment` object with `mode`, `reason`, `seen`, `enriched`, `cleared`, `without_widget`, `partial_errors`, `first_partial_error`, `error` fields
33. **`test_graphql_partial_data_with_errors_returns_data`** — mock returns `{"data":{"foo":"bar"},"errors":[{"message":"partial failure"}]}` → `Ok(GraphqlQueryResult { data: json!({"foo":"bar"}), had_partial_errors: true, first_partial_error: Some("partial failure") })`
34. **`test_fetch_statuses_cursor_stall_aborts`** — mock returns `hasNextPage: true` with same `endCursor` on consecutive pages → pagination aborts with warning, returns partial result collected so far
35. **`test_fetch_statuses_unsupported_reason_none_on_success`** — successful fetch → `unsupported_reason` is `None`
36. **`test_fetch_statuses_complexity_error_reduces_page_size`** — mock returns complexity error on first page with `first=100` → retries with `first=50`, succeeds → result contains all statuses from the successful page
37. **`test_fetch_statuses_timeout_error_reduces_page_size`** — mock returns timeout error on first page with `first=100` → retries with smaller page size, succeeds
38. **`test_fetch_statuses_smallest_page_still_fails`** — mock returns complexity error at all page sizes (100→50→25→10) → returns Err
39. **`test_fetch_statuses_page_size_resets_after_success`** — first page succeeds at 100, second page fails at 100 → falls back to 50 for page 2, page 3 retries at 100 (reset after success)
40. **`test_list_filter_by_multiple_statuses`** — `--status "In progress" --status "To do"` → returns issues matching either status
41. **`test_project_path_missing_skips_enrichment`** — project with no `path_with_namespace` in DB → enrichment skipped, `status_enrichment_error == "project_path_missing"`, sync continues
42. **`test_fetch_statuses_partial_errors_tracked`** — mock returns partial-data response on one page → `partial_error_count == 1`, `first_partial_error` populated

### GREEN Phase — Build Order

**Batch 1: Types + Migration** (Files 3, 4, 14 → Tests 7-10, 15-16, 23-24)
- Create `migrations/021_work_item_status.sql` (5 columns + index)
- Add `WorkItemStatus` struct to `types.rs` (no enum — category is `Option<String>`)
- Register migration 021 in `db.rs`
- Add `fetch_work_item_status` to `config.rs`
- Run: `cargo test test_work_item_status test_migration_021 test_config`

**Batch 2: GraphQL Client** (Files 1, 2, 13 → Tests 1-6, 11-14, 25, 27-29, 33-39, 42)
- Create `src/gitlab/graphql.rs` with full client (`GraphqlQueryResult` struct, partial-error metadata, HTTP-date Retry-After) + fetcher + `FetchStatusResult` (with `partial_error_count`/`first_partial_error`) + `UnsupportedReason` + adaptive page sizing + pagination guard + `ansi256_from_rgb`
- Add `pub mod graphql;` to `gitlab/mod.rs`
- Add `pub fn graphql_client()` factory to `client.rs`
- Add `httpdate` crate to `Cargo.toml` dependencies (for Retry-After HTTP-date parsing)
- Run: `cargo test graphql`

**Batch 3: Orchestrator** (Files 6, 12 → Tests 17-20, 26, 30-32, 41)
- Add enrichment phase (with non-fatal path lookup) + `enrich_issue_statuses_txn()` (with `now_ms` param) to orchestrator
- Add coverage telemetry fields (`statuses_seen`, `statuses_without_widget`, `partial_error_count`, `first_partial_error`) to `IngestProjectResult`
- Add `status_enrichment_error: Option<String>`, `status_enrichment_mode: String`, `status_unsupported_reason: Option<String>` to `IngestProjectResult`
- Add `StatusEnrichmentComplete` + `StatusEnrichmentSkipped` to `ProgressEvent` enum
- Add match arms in `ingest.rs` progress callback
- Run: `cargo test orchestrator`

**Batch 4: CLI Display + Filter** (Files 7-11 → Tests 21-22, 40)
- Add status fields (including `status_synced_at`) to `show.rs` structs, SQL, display
- Add status fields to `list.rs` structs, SQL, column, repeatable `--status` filter (with IN clause + COLLATE NOCASE)
- Add `--status` flag to `cli/mod.rs` as `Vec<String>` (repeatable)
- Add `"--status"` to autocorrect registry
- Wire `statuses` in both `ListFilters` constructions in `main.rs`
- Wire `status_enrichment` (with `mode`/`reason`/`seen`/`without_widget`/`partial_errors`/`first_partial_error` fields) into robot sync output envelope
- Run: `cargo test` (full suite)

**Batch 5: Quality Gates** (AC-11)
- `cargo check --all-targets`
- `cargo clippy --all-targets -- -D warnings`
- `cargo fmt --check`
- `cargo test` (all green)

**Key gotcha per batch:**
- Batch 1: Migration has 5 columns now (including `status_synced_at INTEGER`), not 4 — test assertion must check for all 5
- Batch 2: Use `r##"..."##` in tests containing `"#1f75cb"` hex colors; `FetchStatusResult` is not `Clone` — tests must check fields individually; `__typename` test mock data must include the field in widget JSON objects; `httpdate` crate needed for Retry-After HTTP-date parsing — add to `Cargo.toml`; pagination guard test needs mock that returns same `endCursor` twice; partial-data test needs mock that returns both `data` and `errors` fields; adaptive page size tests need mock that inspects `$first` variable in request body to return different responses per page size; `GraphqlQueryResult` (not raw `Value`) is now the return type — test assertions must destructure it
- Batch 3: Progress callback in `ingest.rs` must be updated in same batch as enum change (2 new arms: `StatusEnrichmentComplete` + `StatusEnrichmentSkipped`); `unchecked_transaction()` needed because `conn` is `&Connection` not `&mut Connection`; `enrich_issue_statuses_txn` takes 5 params now (added `now_ms: i64`) and returns `(usize, usize)` tuple — destructure at call site; `status_enrichment_error` must be populated on the `Err` branch; `status_enrichment_mode` and `status_unsupported_reason` must be set in all code paths; project path lookup uses `.optional()?` — requires `use rusqlite::OptionalExtension;` import
- Batch 4: Autocorrect registry must be updated in same batch as clap flag addition; `COLLATE NOCASE` applies to the comparison, not the column definition; `status_synced_at` is `Option<i64>` in Rust structs (maps to nullable INTEGER in SQLite); `--status` is `Vec<String>` not `Option<String>` — `ListFilters.statuses` is `&[String]`; multi-value filter uses dynamic IN clause with placeholder generation

## Edge Cases

- **GitLab Free tier**: Status widget absent → columns stay NULL, no error
- **GitLab < 17.11**: No status widget at all → same as Free tier
- **GitLab 17.11-18.0**: Status widget present but `category` field missing → store name only
- **Custom statuses (18.5+)**: Names and categories won't match system defaults → stored as raw strings, no deserialization failures
- **Token with `read_api` scope**: Should work for GraphQL queries (read-only)
- **Self-hosted with GraphQL disabled**: 404 on endpoint → graceful skip, `unsupported_reason: GraphqlEndpointMissing`
- **HTTP 403 (Forbidden)**: Treated as auth failure, graceful skip with warning log, `unsupported_reason: AuthForbidden`
- **Rate limiting**: Respect `Retry-After` header if present
- **Large projects (10k+ issues)**: Pagination handles this — adaptive page sizing starts at 100, falls back to 50→25→10 on complexity/timeout errors
- **Self-hosted with strict complexity limits**: Adaptive page sizing automatically reduces `first` parameter to avoid complexity budget exhaustion
- **Status-state sync**: Closed via REST → status might be "Done" — expected and correct
- **Concurrent syncs**: Status enrichment is idempotent — UPDATE is safe to run multiple times
- **Status removed in GitLab**: Issue was fetched but has no status widget → status fields NULLed out (staleness clearing)
- **Enrichment failure mid-project**: Transaction rolls back — no partial updates, prior status values intact. Error message captured in `IngestProjectResult.status_enrichment_error` and surfaced in robot sync output.
- **Project path missing from DB**: Enrichment skipped with structured error (`project_path_missing`), does NOT fail the overall project pipeline — sync continues to discussion phase
- **Retry-After with HTTP-date format**: Parsed to delta-seconds from now via `httpdate` crate. Invalid format falls back to 60s.
- **NULL hex color**: `style_with_hex` and `colored_cell_hex` fall back to unstyled text
- **Invalid hex color**: Malformed color string → fall back to unstyled text
- **Empty project**: `fetch_issue_statuses` returns empty result → no UPDATEs, no error
- **Case-insensitive filter**: `--status "in progress"` matches `"In progress"` via COLLATE NOCASE
- **Multiple status filter**: `--status "To do" --status "In progress"` → OR semantics within status, AND with other filters
- **GraphQL partial-data response**: Response has both `data` and `errors` → data is used, `partial_error_count` incremented, `first_partial_error` captured — agents can detect incomplete data via robot sync output
- **GraphQL cursor stall**: `hasNextPage=true` but `endCursor` is `None` or unchanged → pagination aborted with warning, partial result returned (prevents infinite loops from GraphQL cursor bugs)

## Decisions

1. **Store color + icon_name** — YES. Used for colored CLI output in human view.
2. **Run on every sync** — always enrich, not just `--full`. This is vital data.
3. **Include `--status` filter** — YES, in v1. `lore list issues --status "In progress"`
4. **Factory over raw token** — YES. `client.graphql_client()` keeps token encapsulated.
5. **Transactional enrichment** — YES. All-or-nothing per project prevents partial/stale state.
6. **Case-insensitive `--status` filter** — YES. Better UX, especially for custom status names. ASCII `COLLATE NOCASE` is sufficient — all system statuses are ASCII, and custom names are overwhelmingly ASCII too.
7. **Flat fields over nested JSON object** — YES. Consistent with existing `labels`, `milestone_title` pattern. Works with `--fields` selection. Nesting would break `--fields` syntax and require special dot-path resolution.
8. **No retry/backoff in v1** — DEFER. REST client doesn't have retry either. This is a cross-cutting concern that should be built once as a shared transport layer (`transport.rs`) for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior and maintenance burden. Note: adaptive page sizing (Decision 18) handles the specific case of complexity/timeout errors without needing general retry.
9. **No capability probe/cache in v1** — DEFER. Graceful degradation (empty map on 404/403) is sufficient. The warning is once per project per sync — acceptable noise. Cache table adds migration complexity and a new DB schema concept for marginal benefit.
10. **`status_synced_at` column** — YES. Lightweight enrichment freshness timestamp (ms epoch UTC). Enables consumers to detect stale data and supports future delta-driven enrichment. Written on both status-set and status-clear operations to distinguish "never enriched" (NULL) from "enriched but no status" (timestamp set, status NULL).
11. **Enrichment error in robot output** — YES. `status_enrichment_error: Option<String>` on `IngestProjectResult` + per-project `status_enrichment` object in robot sync JSON. Agents need machine-readable signal when enrichment fails — silent warnings in logs are invisible to automation.
12. **No `status_name_fold` shadow column** — REJECT. `COLLATE NOCASE` handles ASCII case-folding which covers all system statuses. A fold column doubles write cost for negligible benefit.
13. **No `--status-category` / `--no-status` in v1** — DEFER. Keep v1 focused on core `--status` filter. These are easy additions once usage patterns emerge.
14. **No strict mode** — DEFER. A `status_enrichment_strict` config toggle adds config bloat for an edge case. The `status_enrichment_error` field gives agents the signal they need to implement their own strict behavior.
15. **Explicit outcome mode in robot output** — YES. `status_enrichment.mode` distinguishes `"fetched"` / `"unsupported"` / `"skipped"` with optional `reason` field. Resolves the ambiguity where agents couldn't tell "project has no statuses" from "feature unavailable" — both previously looked like an empty success. Cheap to implement since `FetchStatusResult` already has `unsupported_reason`.
16. **GraphQL partial-data tolerance with end-to-end metadata** — YES. When GraphQL returns both `data` and `errors`, use the data and propagate error metadata (`had_partial_errors`/`first_partial_error`) through `GraphqlQueryResult` → `FetchStatusResult` → `IngestProjectResult` → robot sync output. Agents get machine-readable signal that status data may be incomplete, rather than silent log-only warnings.
17. **Pagination guard against cursor stall** — YES. If `hasNextPage=true` but `endCursor` is `None` or unchanged, abort the loop and return partial results. This is a zero-cost safety valve against infinite loops from GraphQL cursor bugs. The alternative (trusting the server unconditionally) risks hanging the sync indefinitely.
18. **Adaptive page sizing** — YES. Start with `first=100`, fall back to 50→25→10 on GraphQL complexity/timeout errors. This is NOT general retry/backoff (Decision 8) — it specifically handles self-hosted GitLab instances with stricter complexity/time limits by reducing the page size that caused the problem. After a successful page, resets to 100 (the complexity issue may be cursor-position-specific). Zero operational cost — adds ~15 lines and 4 tests.
19. **Repeatable `--status` filter** — YES. `--status "To do" --status "In progress"` with OR semantics. Practical for "show me active work" queries. Clap supports `Vec<String>` natively, and dynamic IN clause generation is straightforward. Single-value case uses `=` for simplicity.
20. **Non-fatal project path lookup** — YES. Uses `.optional()?` instead of `?` for the `path_with_namespace` DB query. If the project path is missing, enrichment is skipped with `status_enrichment_error: "project_path_missing"` and the sync continues to discussion phase. Enrichment is optional — it should never take down the entire project pipeline.
21. **Coverage telemetry counters** — YES. `seen`, `enriched`, `cleared`, `without_widget` in `IngestProjectResult` and robot sync output. `enriched`/`cleared` alone cannot distinguish "project has 0 issues" from "project has 500 issues with 0 statuses." Coverage counters cost one `len()` call and one subtraction — negligible.

## Future Enhancements (Not in Scope)

These ideas surfaced during planning (iterations 3-6, cross-model reviews) but are out of scope for this implementation. File as separate beads if/when needed.

### Filters & Querying
- **`--status-category` filter**: Filter by category (`in_progress`, `done`, etc.) for automation — more stable than name-based filtering for custom lifecycles. Consider adding a `COLLATE NOCASE` index on `(project_id, status_category)` when this lands.
- **`--no-status` filter**: Return only issues where `status_name IS NULL` — useful for migration visibility and data quality audits.
- **`--stale-status-days N` filter**: Filter issues where status hasn't changed in N days — requires `status_changed_at` column (see below).

### Enrichment Optimization
- **Delta-driven enrichment**: Skip GraphQL fetch when issue ingestion reports zero changes for a project (optimization for large repos). Use `status_synced_at` (already in v1 schema) to determine last enrichment time. Add `status_full_reconcile_hours` config (default 24) to force periodic full sweep as safety net.
- **`--refresh-status` override flag**: Force enrichment even with zero issue deltas.
- **Capability probe/cache**: Detect status-widget support per project, cache with TTL in a `project_capabilities` table to avoid repeated pointless GraphQL calls on Free tier. Re-probe on TTL expiry (default 24h). The `status_synced_at` column provides a foundation — if a project's issues all have NULL `status_synced_at`, it's likely unsupported.

### Schema Extensions
- **`status_changed_at` column**: Track when status *value* last changed (ms epoch UTC) — enables "how long has this been in progress?" queries, `--stale-status-days N` filter, and `status_age_days` field in robot output. Requires change-detection logic during enrichment (compare old vs new before UPDATE). Note: `status_synced_at` (in v1) records when enrichment last *ran* on the row, not when the status value changed — these serve different purposes.
- **`status_id` column**: Store GitLab's global node ID (e.g., `gid://gitlab/WorkItems::Statuses::Status/1`) for rename-resistant identity. Useful if automations need to track a specific status across renames. Deferred because the global node ID is opaque, instance-specific, and not useful for cross-instance queries or human consumption — `status_name` is the primary user-facing identifier.

### Infrastructure
- **Shared transport with retry/backoff**: Retry 429/502/503/504/network errors with exponential backoff + jitter (max 3 attempts) — should be a cross-cutting concern via shared `transport.rs` layer for both REST and GraphQL clients. Must respect cancellation signal between retry sleeps.
- **Set-based bulk enrichment**: For very large projects (10k+ issues), replace row-by-row UPDATE loop with temp-table + set-based SQL for faster write-lock release. Profile first to determine if this is actually needed — SQLite `prepare_cached` statements may be sufficient.

### Operational
- **Strict mode**: `status_enrichment_strict: bool` config toggle that makes enrichment failure fail the sync. Agents can already implement equivalent behavior by checking `status_enrichment_error` in the robot sync output (added in v1).
- **`partial_failures` array in robot sync envelope**: Aggregate all enrichment errors into a top-level array for easy agent consumption. Currently per-project `status_enrichment.error` provides the data — this would be a convenience aggregation.
- **Status/state consistency checks**: Detect status-state mismatches (e.g., `DONE` category with `state=open`, or `IN_PROGRESS` category with `state=closed`) and log warnings. Useful for data integrity diagnostics, but status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected — not appropriate for v1 enrichment logic.

### Cancellation
- **Pass cancellation signal into fetcher**: Thread the existing `CancellationSignal` into `fetch_issue_statuses()` to check between page requests. Currently the orchestrator checks cancellation before enrichment starts, and individual page fetches complete in <1s, so the worst-case delay before honoring cancellation is ~1s per page (negligible). Worth adding if enrichment grows to include multiple fetch operations per project.

---

## Battle-Test Results (Iteration 2, pre-revision)

> This plan was fully implemented in a trial run. All 435+ tests passed, clippy clean, fmt clean.
> The code was then reverted so the plan could be reviewed before real implementation.
> The corrections below are already reflected in the code snippets above.
>
> **Note (Iteration 3):** The plan was revised based on external review feedback. Key changes:
> transactional enrichment with staleness clearing, `graphql_client()` factory pattern,
> 403 handling, compound index, case-insensitive filter, 5 additional tests, and dropped
> `WorkItemStatusCategory` enum in favor of `Option<String>` (custom statuses on 18.5+ can
> have arbitrary category values). The trial implementation predates these revisions — the
> next trial should validate the new behavior.
>
> **Note (Iteration 4):** Revised based on cross-model review (ChatGPT). Key changes:
> `__typename`-based deterministic widget matching (replaces heuristic try-deserialize),
> `all_fetched_iids` changed from `Vec<i64>` to `HashSet<i64>` for O(1) staleness lookups,
> `statuses_cleared` counter added for enrichment observability, `StatusEnrichmentSkipped`
> progress event for config-off case, idempotency test added, expanded future enhancements
> with deferred ideas (status_changed_at, capability cache, shared transport, set-based bulk).
> Rejected scope-expanding suggestions: shared transport layer, capability cache table,
> delta-driven enrichment, NOCASE index changes, `--status-category`/`--no-status` filters,
> `status_changed_at` column — all deferred to future enhancements with improved descriptions.
>
> **Note (Iteration 5):** Revised based on second cross-model review (ChatGPT feedback-5).
> **Accepted:** (1) `status_synced_at INTEGER` column — lightweight freshness timestamp for
> enrichment observability and future delta-driven optimization, written on both set and clear
> operations; (2) `status_enrichment_error: Option<String>` on `IngestProjectResult` — captures
> error message for machine-readable failure reporting; (3) AC-10 for robot sync envelope with
> per-project `status_enrichment` object; (4) `Retry-After` HTTP-date format support via
> `httpdate` crate; (5) 5 new tests (HTTP-date, clear synced_at, error capture, sync envelope).
> **Rejected with rationale:** retry/backoff (cross-cutting, not GraphQL-only), capability cache
> (migration complexity for marginal benefit), delta-driven enrichment (profile first),
> set-based SQL (premature optimization), strict mode (agents can use error field), Unicode
> case-folding column (ASCII COLLATE NOCASE sufficient), nested JSON status object (breaks
> --fields syntax), `--status-category`/`--no-status` filters (keep v1 focused).
> Expanded Decisions section from 9 to 14 with explicit rationale for each rejection.
> Reorganized Future Enhancements into categorized subsections.
>
> **Note (Iteration 6):** Revised based on third cross-model review (ChatGPT feedback-6).
> **Accepted:** (1) GraphQL partial-data tolerance — when response has both `data` and `errors`,
> return data and log warning instead of hard-failing (per GraphQL spec, partial results are valid);
> (2) Pagination guard — abort loop if `hasNextPage=true` but `endCursor` is `None` or unchanged
> (prevents infinite loops from GraphQL cursor bugs); (3) `UnsupportedReason` enum +
> `unsupported_reason` field on `FetchStatusResult` — distinguishes "no statuses found" from "feature
> unavailable" for robot sync output clarity; (4) Enhanced AC-10 with `mode`/`reason` fields for
> explicit enrichment outcome reporting. Added 3 new tests (partial-data, cursor stall, unsupported
> reason on success). Updated Decisions 15-17. Added `status_id` to Future Enhancements.
> **Rejected with rationale:** see Rejected Recommendations section below.
>
> **Note (Iteration 7):** Revised based on fourth cross-model review (ChatGPT feedback-7).
> **Accepted:** (1) Partial-error metadata end-to-end — `GraphqlQueryResult` struct replaces raw
> `Value` return, propagating `had_partial_errors`/`first_partial_error` through `FetchStatusResult`
> → `IngestProjectResult` → robot sync output (agents get machine-readable incomplete-data signal);
> (2) Adaptive page sizing — `first=100→50→25→10` fallback on complexity/timeout errors, handles
> self-hosted instances with stricter limits without needing general retry/backoff; (3) Non-fatal
> project path lookup — `.optional()?` prevents enrichment from crashing the project pipeline;
> (4) Repeatable `--status` filter — `Vec<String>` with OR semantics via IN clause; (5) Coverage
> telemetry — `seen`, `without_widget`, `partial_error_count`, `first_partial_error` counters on
> `IngestProjectResult` for full observability. Added 7 new tests (adaptive page size ×4, multi-status,
> path missing, partial errors tracked). Updated Decisions 16, 18-21.
> **Rejected with rationale:** centralized color parsing module (see Rejected Recommendations).

### Corrections Made During Trial

| Issue | Root Cause | Fix Applied |
|---|---|---|
| `LoreError::GitLabRateLimited { retry_after }` uses `u64`, not `Option<u64>` | Plan assumed `Option<u64>` for retry_after | Added `.unwrap_or(60)` after parsing Retry-After header |
| Raw string `r#"..."#` breaks on `"#1f75cb"` in test JSON | The `"#` sequence matches the raw string terminator | Use `r##"..."##` double-hash delimiters in test code |
| Clippy: collapsible if statements (5 instances) | `if x { if y { ... } }` → `if x && y { ... }` | Used `if ... && let ...` chain syntax (Rust 2024 edition) |
| Clippy: items after test module | `ansi256_from_rgb()` was placed after `#[cfg(test)]` | Moved function before the test module |
| Clippy: manual range contains | `assert!(blue >= 16 && blue <= 231)` | Changed to `assert!((16..=231).contains(&blue))` |
| Missing token accessor on `GitLabClient` | Orchestrator needs token to create `GraphqlClient` | Added `pub fn token(&self) -> &str` to `client.rs` |
| Missing `path_with_namespace` in orchestrator | Only had `project_id`, needed project path for GraphQL | Added DB query to look up path from projects table |
| Exhaustive match on `ProgressEvent` | Adding enum variant breaks `ingest.rs` match | Added `StatusEnrichmentComplete` arm to progress callback |
| Legacy `List` command missing `status` field | Two `ListFilters` constructions in `main.rs` | Added `status: None` to legacy command's `ListFilters` |
| Autocorrect registry test failure | All clap flags must be registered in `COMMAND_FLAGS` | Added `"--status"` to the `"issues"` entry in `autocorrect.rs` |

### Files Not in Original Plan (Discovered During Trial)

- `migrations/021_work_item_status.sql` — separate file, not inline in db.rs (uses `include_str!`)
- `src/gitlab/client.rs` — needs `graphql_client()` factory for GraphQL client creation (revised from `token()` accessor)
- `src/cli/commands/ingest.rs` — progress callback match must be updated
- `src/cli/autocorrect.rs` — flag registry test requires all new flags registered
- `src/main.rs` — legacy `List` command path also constructs `ListFilters`

### Test Results from Trial (all green, pre-revision)

- 435 lib tests + 136 integration/benchmark tests = 571 total
- 11 new GraphQL tests (client, status fetcher, auth, pagination, graceful degradation)
- 5 new orchestrator tests (migration, enrich, skip unknown IIDs, progress event, result default)
- 3 new config tests (default true, deserialize false, omitted defaults true)
- All existing tests remained green

**Post-revision additions (5 new tests, iteration 3):** `test_graphql_403_maps_to_auth_failed`,
`test_migration_021_adds_index`, `test_enrich_clears_removed_status`,
`test_enrich_transaction_rolls_back_on_failure`, `test_list_filter_by_status_case_insensitive`

**Post-revision additions (2 new tests, iteration 4):** `test_enrich_idempotent_across_two_runs`,
`test_typename_matching_ignores_non_status_widgets`

**Post-revision additions (5 new tests, iteration 5):** `test_retry_after_http_date_format`,
`test_retry_after_invalid_falls_back_to_60`, `test_enrich_sets_synced_at_on_clear`,
`test_enrichment_error_captured_in_result`, `test_robot_sync_includes_status_enrichment`

**Post-revision additions (3 new tests, iteration 6):** `test_graphql_partial_data_with_errors_returns_data`,
`test_fetch_statuses_cursor_stall_aborts`, `test_fetch_statuses_unsupported_reason_none_on_success`

**Post-revision additions (7 new tests, iteration 7):** `test_fetch_statuses_complexity_error_reduces_page_size`,
`test_fetch_statuses_timeout_error_reduces_page_size`, `test_fetch_statuses_smallest_page_still_fails`,
`test_fetch_statuses_page_size_resets_after_success`, `test_list_filter_by_multiple_statuses`,
`test_project_path_missing_skips_enrichment`, `test_fetch_statuses_partial_errors_tracked`

---

## Rejected Recommendations

Cumulative log of recommendations considered and rejected across iterations. Prevents future reviewers from re-proposing the same changes.

### Iteration 4 (ChatGPT feedback-4)
- **Shared transport layer with retry/backoff** — rejected because this is a cross-cutting concern that should be built once for both REST and GraphQL, not bolted onto GraphQL alone. Adding retry only for GraphQL creates split behavior. Deferred to Future Enhancements.
- **Capability cache table** — rejected because migration complexity and new DB schema concept for marginal benefit. Graceful degradation (empty map on 404/403) is sufficient.
- **Delta-driven enrichment** — rejected because needs profiling first to determine if full re-fetch is actually a bottleneck. Premature optimization.
- **NOCASE index changes** — rejected because `COLLATE NOCASE` on the comparison is sufficient; adding it to the index definition provides no benefit for SQLite's query planner in this case.
- **`--status-category` / `--no-status` filters in v1** — rejected because keeping v1 focused. Easy additions once usage patterns emerge.
- **`status_changed_at` column in v1** — rejected because requires change-detection logic during enrichment (compare old vs new) which adds complexity. `status_synced_at` serves different purpose and is sufficient for v1.

### Iteration 5 (ChatGPT feedback-5)
- **Retry/backoff (again)** — rejected for same reason as iteration 4. Cross-cutting concern.
- **Capability cache (again)** — rejected for same reason as iteration 4.
- **Delta-driven enrichment (again)** — rejected for same reason as iteration 4.
- **Set-based bulk SQL** — rejected because premature optimization. SQLite `prepare_cached` statements may be sufficient. Profile first.
- **Strict mode config toggle** — rejected because adds config bloat for edge case. Agents can implement equivalent behavior by checking `status_enrichment_error`.
- **Unicode case-folding shadow column (`status_name_fold`)** — rejected because `COLLATE NOCASE` handles ASCII case-folding which covers all system statuses. Doubles write cost for negligible benefit.
- **Nested JSON status object** — rejected because breaks `--fields` syntax and requires special dot-path resolution. Flat fields are consistent with existing patterns.
- **`--status-category` / `--no-status` filters (again)** — rejected for same reason as iteration 4.

### Iteration 6 (ChatGPT feedback-6)
- **`FetchStatusOutcome` enum with `CancelledPartial` variant** — rejected because over-engineered for v1. The simpler approach of adding `unsupported_reason: Option<UnsupportedReason>` to `FetchStatusResult` provides the same observability signal (distinguishing "no statuses" from "feature unavailable") without introducing a 3-variant enum that forces match arms everywhere. The partial-from-cancellation case is not needed since the orchestrator checks cancellation before starting enrichment and individual page fetches complete in <1s.
- **Pass cancellation signal into `fetch_issue_statuses()`** — rejected because the orchestrator already checks cancellation before enrichment starts, and individual page fetches are <1s. Threading the signal through adds a parameter and complexity for negligible benefit. Deferred to Future Enhancements (Cancellation section) in case enrichment grows to include multiple fetch operations.
- **Persist `status_id` column (GitLab global node ID)** — rejected because GitLab's GraphQL `id` is a global node ID (e.g., `gid://gitlab/WorkItems::Statuses::Status/1`) that is opaque, instance-specific, and not useful for cross-instance queries or human consumption — the `name` is what users see and filter by. Added to Future Enhancements (Schema Extensions) as a deferred option for rename-resistant identity if needed.
- **Extract status enrichment into `src/ingestion/enrichment/status.rs` module** — rejected because the enrichment logic is ~60 lines (one orchestrator block + one helper function). Creating a new module, directory, and `mod.rs` for this is premature abstraction. The orchestrator is the right home until enrichment grows to justify extraction.
- **Status/state consistency checks (mismatch detection)** — rejected because status-state sync is GitLab's responsibility and temporary mismatches during sync windows are expected behavior. Adding mismatch detection to enrichment adds complexity for a diagnostic signal that would generate false positives. Deferred to Future Enhancements (Operational).
- **Performance envelope acceptance criterion (AC-12)** — rejected because "10k-issue fixture on CI baseline machine" is unmeasurable without CI infrastructure and test fixtures we don't have. Pagination handles large projects by design (100/page). Testing memory bounds requires test harness complexity far beyond the value for v1.

### Iteration 7 (ChatGPT feedback-7)
- **Centralize color parsing into `src/cli/commands/color.rs` module** — rejected because the two color helpers (`style_with_hex` in show.rs, `colored_cell_hex` in list.rs) return different types (`console::StyledObject` vs `comfy_table::Cell`) for different rendering contexts. The shared hex-parsing logic is 4 lines. Creating a new module + file for this is premature abstraction per project rules ("only abstract when you have 3+ similar uses"). If a third color context emerges, extract then.
