# Phase 12: Production Data Client Integration - Research

**Researched:** 2026-03-20
**Domain:** Rust integration wiring — `gh` CLI subprocess, toml config, Slint/Rc threading, service crate bridging
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Client implementation:**
- Production client wraps the service crate's Repository directly — no intermediate API layer
- Lives in its own module: `crates/app/src/live_client.rs` — keeps main.rs clean, testable separately
- NoopClient stays for tests
- Production client owns the sync scheduler — starts 5-minute auto-poll internally. Main.rs just creates the client and uses it
- Service-layer types (RecipientSnapshot, etc.) mapped to app-layer types (RecipientCardSnapshot) inside the client

**Startup flow:**
- Offline-first design — app always reads from local persistent store. Syncs when service is reachable. Queues mutations when disconnected, commits when reconnected
- First-ever launch with no prior data: shows empty state
- Subsequent launches: shows locally cached data immediately, syncs in background
- Background bootstrap — UI renders immediately with local data. Service connection and first sync happen on a background thread. Cards populate when sync completes
- Subtle status indicator — small icon/text in header showing connection state (connected/disconnected). Non-intrusive, always visible

**GitHub API access:**
- All GitHub API access uses the `gh` CLI command — already authenticated on the machine, no GitHub token management needed in the app
- The app shells out to `gh` for GitHub Project data fetching (e.g., `gh api graphql` for project queries)
- No GitHub token in config file or Windows Credential Manager — `gh` handles auth entirely
- This simplifies config: only Shopify credentials need to be managed by the app

**Config and credentials:**
- Config file at `%APPDATA%/WITwhat/config.toml` — standard Windows user app data location, not in project directory
- Config file holds non-secret settings (store slug, GitHub project URL) plus Shopify `secret_ref` keys (e.g., `wincred:shopify/token`) resolved by CredentialLoader at runtime
- GitHub auth is handled by `gh` CLI — not stored in config or credential manager
- Shopify secrets stored in Windows Credential Manager via existing WindowsCredentialManagerStore
- In-app config setup — if config is missing or incomplete on first run, app starts empty and provides UI to set up Shopify credentials and settings. A settings button resurfaces this setup. No placeholder files
- `SHOPIFY_STORE_SLUG` in config (non-secret), passed to projection layer per Phase 11 decision

### Claude's Discretion
- Exact config.toml schema and field names
- How the sync scheduler timer is implemented (std::thread sleep loop vs tokio vs other)
- Connection state indicator placement and styling
- How mutation queue persistence works (in-memory vs file-based)
- Settings UI layout and flow

### Deferred Ideas (OUT OF SCOPE)
- Setup wizard with step-by-step credential entry — could be a future UX enhancement beyond basic settings UI
- Mutation queue sync-on-reconnect — full offline mutation queue may need its own phase if complex
- Auto-update mechanism for the app binary
</user_constraints>

---

## Summary

This phase wires the app binary to live data by replacing `NoopClient` with a `LiveClient` that drives the existing service crate's `Repository` using real GitHub Projects data (via `gh` CLI subprocess) and Shopify API data (via `ureq` with token from Windows Credential Manager). The key architectural challenge is that `DashboardDataClient` is used behind `Rc<C>` on the Slint UI thread, so the production client must be single-threaded — background sync happens via a `std::thread` that writes into an `Arc<Mutex<Repository>>` shared with the `Rc`-wrapped client.

The second major challenge is the config layer: `%APPDATA%/WITwhat/config.toml` must be read at startup and surfaced for in-app setup when missing. The `toml` crate is already in the workspace lock file (v0.9.12), and `dirs` (v5) is already in the app crate — both are available for immediate use.

`gh` CLI is installed at `C:/Program Files/GitHub CLI/gh.exe` (v2.88.1, authenticated). The app must discover the `gh` binary at runtime using `std::process::Command` — hard-coding the path is not portable, so the search strategy should check `gh` in PATH first, then fall back to the known Windows installation path.

**Primary recommendation:** Implement `LiveClient` as a struct owning an `Arc<Mutex<Repository>>` + `Arc<Mutex<ServiceRuntime>>`. Background sync thread runs every 5 minutes using `std::thread::sleep`. All `DashboardDataClient` methods read from the `Arc<Mutex<Repository>>`. Config is loaded from `%APPDATA%/WITwhat/config.toml` at startup; missing config triggers empty-state UI with settings panel.

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `toml` | 0.9.12 (already in lock) | Parse `config.toml` | Already in workspace, serde-compatible |
| `serde` | 1.x (already in app) | Derive `Deserialize` for config structs | Already in app crate |
| `dirs` | 5.0.1 (already in app) | Resolve `%APPDATA%` → config path | Already in app crate |
| `ureq` | 2.12.1 (already in app) | Shopify REST calls | Already in app crate |
| `serde_json` | 1.0.149 (already in lock) | Parse `gh` CLI JSON output | Already available |
| `std::process::Command` | stdlib | Shell out to `gh` CLI | No extra dep needed |
| `std::sync::{Arc, Mutex}` | stdlib | Share Repository between UI thread and sync thread | Required for cross-thread state |
| `std::thread` | stdlib | Background sync loop | Simpler than tokio for this use case |

### Not Needed (and why)
| Avoided | Reason |
|---------|--------|
| `tokio` | No async runtime needed; `std::thread::sleep` loop suffices for 5-minute polling |
| GitHub HTTP client / `octocrab` | Replaced by `gh` CLI subprocess — no token management needed |
| Additional credential crates | `WindowsCredentialManagerStore` already exists in service crate |

**Installation — new deps to add to `crates/app/Cargo.toml`:**
```toml
toml = "0.9"
serde_json = "1"
service = { path = "../service" }
integrations = { path = "../integrations" }
```

`toml` and `serde_json` are already in the workspace lock file. `service` and `integrations` are the new dependency links for the app crate.

**Version verification:** All versions confirmed against workspace `Cargo.lock` — `toml` 0.9.12, `serde_json` 1.0.149, `dirs` 5.0.1, `ureq` 2.12.1.

---

## Architecture Patterns

### Recommended Project Structure
```
crates/app/src/
├── live_client.rs           # LiveClient: implements DashboardDataClient
├── config.rs                # AppConfig struct, load_config(), save_config()
├── main.rs                  # Unchanged structure: replace NoopClient with LiveClient
└── dashboard/               # Unchanged
```

### Pattern 1: Arc<Mutex<Repository>> for Cross-Thread State

**What:** `LiveClient` owns an `Arc<Mutex<Repository>>` and an `Arc<Mutex<ServiceRuntime>>`. The `Rc<LiveClient>` in main.rs accesses data on the UI thread via `.lock().unwrap()`. A detached `std::thread` runs the sync loop and writes updated data into the same `Arc<Mutex<Repository>>` after each sync cycle.

**Why this works despite `Rc`:** `Rc<LiveClient>` is never sent across threads — only the `Arc<Mutex<Repository>>` inside `LiveClient` is cloned and sent to the background thread. `Rc` stays on the UI thread.

**Example:**
```rust
// Source: stdlib docs — Arc/Mutex cross-thread pattern
pub struct LiveClient {
    repo: Arc<Mutex<Repository>>,
    runtime: Arc<Mutex<ServiceRuntime>>,
    config: AppConfig,
}

impl LiveClient {
    pub fn new(config: AppConfig) -> Self {
        let repo = Arc::new(Mutex::new(Repository::new()));
        let runtime = Arc::new(Mutex::new(ServiceRuntime::new()));
        let client = LiveClient { repo: Arc::clone(&repo), runtime: Arc::clone(&runtime), config };
        // Spawn background sync thread
        let repo_bg = Arc::clone(&repo);
        let runtime_bg = Arc::clone(&runtime);
        let config_bg = client.config.clone();
        std::thread::spawn(move || {
            loop {
                // run one sync cycle
                sync_once(&repo_bg, &runtime_bg, &config_bg);
                std::thread::sleep(std::time::Duration::from_secs(300));
            }
        });
        client
    }
}
```

**Compiler note:** `Rc<LiveClient>` satisfies the `C: DashboardDataClient` bound in main.rs because the trait does not require `Send`. `Arc<Mutex<...>>` inside `LiveClient` requires `Repository: Send + Sync`. `Repository` is `Default` + pure in-memory HashMap — it is `Send` + `Sync` automatically since `HashMap<String, T>` where T: Send is Send.

### Pattern 2: gh CLI Subprocess for GitHub Projects

**What:** Shell out to `gh api graphql` using `std::process::Command`. Parse the JSON output with `serde_json`. Map response fields to `GithubProjectRow` to feed existing `ingest_github_recipients` pipeline.

**gh binary discovery — use this exact logic:**
```rust
// Source: verified against gh 2.88.1 behavior
fn find_gh() -> Option<std::path::PathBuf> {
    // 1. Try PATH first (portable)
    if std::process::Command::new("gh").arg("--version").output().is_ok() {
        return Some(std::path::PathBuf::from("gh"));
    }
    // 2. Windows known install path
    let known = std::path::Path::new("C:/Program Files/GitHub CLI/gh.exe");
    if known.exists() {
        return Some(known.to_path_buf());
    }
    None
}
```

**GraphQL query pattern for GitHub Projects v2:**
```rust
// Source: gh api graphql --help, confirmed against gh 2.88.1
fn fetch_github_project_rows(
    gh_path: &Path,
    project_node_id: &str,
) -> Result<Vec<GithubProjectRow>, String> {
    let query = r#"
      query($project: ID!, $cursor: String) {
        node(id: $project) {
          ... on ProjectV2 {
            items(first: 100, after: $cursor) {
              pageInfo { hasNextPage endCursor }
              nodes {
                id
                fieldValues(first: 20) {
                  nodes {
                    ... on ProjectV2ItemFieldTextValue {
                      field { ... on ProjectV2Field { name } }
                      text
                    }
                    ... on ProjectV2ItemFieldSingleSelectValue {
                      field { ... on ProjectV2Field { name } }
                      name
                    }
                    ... on ProjectV2ItemFieldUrlValue {
                      field { ... on ProjectV2Field { name } }
                      url
                    }
                  }
                }
              }
            }
          }
        }
      }
    "#;
    let output = std::process::Command::new(gh_path)
        .args(["api", "graphql", "-f", &format!("query={}", query), "-F", &format!("project={}", project_node_id)])
        .output()
        .map_err(|e| format!("gh exec failed: {}", e))?;
    // parse output.stdout as JSON, map to GithubProjectRow
    ...
}
```

**Key implementation note:** The existing `GithubProjectClient` trait (`fetch_rows(project_id, view_id) -> Result<Vec<GithubProjectRow>>`) is already defined in `crates/integrations/src/github/project_client.rs`. The production implementation is a new struct `GhCliProjectClient` that implements this trait — the existing `ingest_github_recipients` pipeline consumes it without change.

### Pattern 3: Config File Loading

**What:** `AppConfig` is a serde-deserializable struct loaded from `%APPDATA%/WITwhat/config.toml`. If the file is missing or any required field is absent, `load_config()` returns `None` and the app starts in empty/setup mode.

**Config location:** `dirs::config_dir()` returns `%APPDATA%` on Windows (e.g., `C:\Users\decid\AppData\Roaming`). The CONTEXT.md says `%APPDATA%/WITwhat/config.toml`.

**Note:** `dirs::data_local_dir()` returns `%APPDATA%/Local` (used by archive_store.json and pending_edits.json). For config, the correct call is `dirs::config_dir()` which returns `%APPDATA%/Roaming` — this is the standard location for user config on Windows.

```rust
// crates/app/src/config.rs
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
    pub shopify_store_slug: String,
    pub shopify_secret_ref: String,   // e.g. "wincred:shopify/token"
    pub github_project_node_id: String,  // GitHub ProjectV2 node ID
}

pub fn load_config() -> Option<AppConfig> {
    let path = dirs::config_dir()?.join("WITwhat").join("config.toml");
    let text = std::fs::read_to_string(path).ok()?;
    toml::from_str(&text).ok()
}

pub fn save_config(config: &AppConfig) -> Result<(), String> {
    // serialize and write — uses toml::to_string (if feature enabled) or manual format
    ...
}
```

**toml serialization note:** `toml` 0.9.x requires the `display` or `serialize` feature for `toml::to_string`. Check Cargo.toml feature flags. Alternatively, write config manually as formatted TOML string (safe for simple flat structs).

### Pattern 4: In-App Settings UI (empty-state detection)

**What:** On startup, attempt `load_config()`. If `None`, the app shows the settings/setup panel instead of cards. A settings button in the header always resurfaces the panel.

**Integration point in main.rs:**
```rust
// Replace in main():
let config = config::load_config();
let client: Rc<dyn DashboardDataClient> = match config {
    Some(cfg) => Rc::new(LiveClient::new(cfg)),
    None => Rc::new(NoopClient),  // empty state until configured
};
// Wire settings button → show settings panel
// On settings save → reload config, recreate LiveClient, reload cards
```

### Anti-Patterns to Avoid

- **Hard-coding `C:/Program Files/GitHub CLI/gh.exe`** — always try PATH first; Windows users may have installed `gh` differently or via winget/scoop.
- **Blocking the UI thread with `gh` invocation** — `gh api graphql` can take 1-5 seconds. The sync cycle runs on the background thread, not in `DashboardDataClient::fetch_card_snapshots()`.
- **`fetch_card_snapshots()` calling `gh` directly** — this method is called on the UI thread. It must only read from the in-memory `Repository`. Sync (which calls `gh`) is background-only.
- **`Arc<RefCell<...>>` instead of `Arc<Mutex<...>>`** — `RefCell` is `!Sync`, cannot be shared across threads. Must use `Mutex`.
- **Storing Shopify token in config.toml** — config holds `secret_ref` string only (e.g., `"wincred:shopify/token"`). The actual token is resolved at runtime via `CredentialLoader`.
- **Using `dirs::data_local_dir()` for config** — that gives `%APPDATA%\Local`. Use `dirs::config_dir()` for `%APPDATA%\Roaming\WITwhat\config.toml` (the standard Windows config location).

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| GitHub GraphQL queries | Custom HTTP client with token | `std::process::Command` to `gh api graphql` | Auth already handled, no token management |
| JSON parsing of gh output | Custom parser | `serde_json::from_slice` on `output.stdout` | serde_json already in workspace |
| GitHub project row mapping | New field extraction logic | Existing `map_rows()` + `GithubProjectRow` types | Fully implemented in `project_mapping.rs` |
| GitHub ingest diffing | New diff logic | Existing `ingest_github_recipients()` + `compute_ingest_diff()` | Fully implemented in `github_ingest.rs` |
| Shopify customer linkage | New matching logic | Existing `link_recipients()` + `match_customer()` | Fully implemented in `shopify_linker.rs` |
| Shopify shipment projection | New status logic | Existing `project_shipment_for_customer()` | Fully implemented in `shopify_projection.rs` |
| Recipient merge | Custom merge | Existing `merge_recipient()` | Fully implemented in `sync/merge.rs` |
| Credential resolution | Custom Windows Credential Manager | Existing `CredentialLoader` + `WindowsCredentialManagerStore` | Fully implemented in service crate |
| Config path resolution | `%APPDATA%` string manipulation | `dirs::config_dir()` | `dirs` already in app crate |
| Sync scheduling state | Custom timer | Existing `ServiceRuntime` + `SyncScheduler` | Fully implemented with backoff/jitter |
| RecipientSnapshot → RecipientCardSnapshot mapping | New projection layer | Thin field-copy mapping in `live_client.rs` | Types are near-identical, trivial map |

**Key insight:** The entire data pipeline — GitHub ingest, Shopify link, shipment projection, recipient merge, snapshot projection — is already implemented and tested in the service/integrations crates. Phase 12 is assembly and wiring, not new logic.

---

## Common Pitfalls

### Pitfall 1: Rc<LiveClient> and Send Requirements

**What goes wrong:** Compiler rejects `std::thread::spawn(move || { ... repo_borrow ... })` if any captured type is `!Send`. `Rc<LiveClient>` is `!Send`, but the background thread must not capture it.

**Why it happens:** `Rc` is not thread-safe by design. Capturing an `Rc` in a `move` closure sent to `std::thread::spawn` is a compile error.

**How to avoid:** Only clone the `Arc<Mutex<Repository>>` and `Arc<Mutex<ServiceRuntime>>` into the background closure — never the `Rc<LiveClient>` or anything that borrows it.

**Warning signs:** Compiler error `Rc<T> cannot be sent between threads safely` — fix by ensuring the spawned closure captures only `Arc`-wrapped data.

### Pitfall 2: gh Binary Not in PATH (bash environment)

**What goes wrong:** In the bash shell used by the dev environment, `gh` is not in `$PATH` (confirmed: `command not found`). But Windows native processes do have `C:/Program Files/GitHub CLI/gh.exe`. `std::process::Command::new("gh")` uses the process's PATH, which on Windows includes the standard Program Files locations.

**Why it happens:** The Rust process runs natively on Windows (not in bash), so it inherits the Windows PATH which includes `gh`. The bash shell used by the developer has a different PATH.

**How to avoid:** In `find_gh()`, try `Command::new("gh")` first (works when running as a Windows native process). Fall back to the known path `C:/Program Files/GitHub CLI/gh.exe`. Log which path is used.

**Warning signs:** `gh exec failed: program not found` at runtime — add the fallback path.

### Pitfall 3: UI Thread Blocking During First Sync

**What goes wrong:** First sync after startup calls `gh api graphql` + Shopify API — this can take 3-10 seconds. If done on the UI thread, the window freezes.

**Why it happens:** `DashboardDataClient::refresh_all()` is called from UI callbacks in main.rs. If `refresh_all()` calls `gh`, the Slint event loop blocks.

**How to avoid:** `refresh_all()` must only signal the background thread to run a sync cycle immediately (e.g., via a flag or channel), not run the sync itself. The background thread posts results back; cards update asynchronously.

**Warning sign:** Window becomes unresponsive after pressing Refresh — always move network I/O off the UI thread.

### Pitfall 4: Slint Weak Handle for Cross-Thread UI Update

**What goes wrong:** After background sync completes, updating the Slint model from a non-UI thread panics or is silently ignored.

**Why it happens:** Slint's model is `!Send`; all model mutations must happen on the thread that owns the `ComponentHandle`.

**How to avoid:** Use `slint::invoke_from_event_loop` to post a closure back to the UI thread after sync completes. The background thread signals completion; the UI thread reads from `Arc<Mutex<Repository>>` and updates the model.

```rust
// Pattern: background thread signals UI thread
let weak = window.as_weak();
let repo = Arc::clone(&self.repo);
std::thread::spawn(move || {
    // ... do sync ...
    let _ = slint::invoke_from_event_loop(move || {
        if let Some(w) = weak.upgrade() {
            // read from repo, update model
        }
    });
});
```

**Warning sign:** Slint panics with "called from wrong thread" or cards don't update after sync.

### Pitfall 5: toml Crate Feature for Serialization

**What goes wrong:** `toml::to_string(config)` fails to compile because the `serialize` feature is not enabled.

**Why it happens:** `toml` 0.9.x splits deserialization and serialization into separate features. The workspace Cargo.lock has `toml` but the app `Cargo.toml` must explicitly list the `toml` crate with the required features.

**How to avoid:** Add `toml = { version = "0.9", features = ["display"] }` if writing config back. For read-only startup config, no extra features needed beyond the default.

### Pitfall 6: GitHub Project Node ID vs Project Number

**What goes wrong:** `gh api graphql` query for Projects v2 requires the node ID (e.g., `PVT_kwDOBH1234`) not the numeric project number.

**Why it happens:** GitHub Projects v2 uses node IDs in GraphQL. Project numbers are shown in the UI URL but are user-scoped numeric IDs, not global node IDs.

**How to avoid:** Config field should store the node ID. Provide a helper or doc note explaining how to get it: `gh api graphql -f query='{ viewer { projectsV2(first:10) { nodes { id title } } } }'`.

---

## Code Examples

Verified patterns from codebase inspection:

### RecipientSnapshot → RecipientCardSnapshot mapping
```rust
// Source: crates/app/src/service_client.rs (RecipientCardSnapshot fields)
//         crates/service/src/api/recipients.rs (RecipientSnapshot fields)
fn map_snapshot(s: &service::api::recipients::RecipientSnapshot) -> RecipientCardSnapshot {
    RecipientCardSnapshot {
        recipient_id: s.recipient_id.clone(),
        recipient_name: s.recipient_name.clone(),
        discord_username: s.discord_username.clone(),
        discord_user_id: s.discord_user_id.clone(),
        github_profile_url: s.github_profile_url.clone(),
        shopify_customer_id: s.shopify_customer_id.clone(),
        shopify_order_id: s.shopify_order_id.clone(),
        email: s.email.clone(),
        shipment_status: s.shipment_status.clone(),
        shipment_status_date: s.shipment_status_date.clone(),
        item_summary: s.item_summary.clone(),
        latest_note: s.latest_note.clone(),
        first_item_image_hint: s.first_item_image_hint.clone(),
        partial_data: s.partial_data,
        last_updated_at: Some(SystemTime::now()),
    }
}
```

### Existing main.rs integration points
```rust
// Source: crates/app/src/main.rs lines 599, 684

// Line 599: Replace
let all_cards = seed_cards();
// With:
let all_cards = client.fetch_card_snapshots()
    .iter()
    .map(|s| project_snapshot_to_card_data(s, &store_slug))
    .collect::<Vec<_>>();

// Line 684: Replace
let client = Rc::new(NoopClient);
// With:
let client: Rc<dyn DashboardDataClient> = match config::load_config() {
    Some(cfg) => Rc::new(live_client::LiveClient::new(cfg)),
    None => Rc::new(NoopClient),
};
```

### Config TOML schema (recommended)
```toml
# %APPDATA%/WITwhat/config.toml
shopify_store_slug = "my-store"
shopify_secret_ref = "wincred:shopify/token"
github_project_node_id = "PVT_kwDOBH1234"
```

### Slint connection status indicator
The existing `DashboardWindow` in Slint UI would need a new property exposed from Rust:
```rust
// Slint side: in-property bool connection-ok (or int for Live/Degraded/Offline)
// Rust side: window.set_connection_status(ConnectionStatus::Live as i32)
```

---

## State of the Art

| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| `seed_cards()` hardcoded data | `client.fetch_card_snapshots()` from Repository | Requires real config to show cards |
| `let client = Rc::new(NoopClient)` | `Rc::new(LiveClient::new(cfg))` | Real data when config present |
| `"teststore"` hardcoded shop domain | `config.shopify_store_slug` | Read from config.toml |
| GitHub token in credential manager | `gh` CLI subprocess | No token management in app |

---

## Open Questions

1. **GitHub Project node ID format in config**
   - What we know: `gh api graphql` requires the `PVT_...` node ID
   - What's unclear: Does the user know their project's node ID, or should there be a "fetch by project number" helper?
   - Recommendation: Config stores the node ID; add a comment in the config file explaining the query to retrieve it. Defer discovery UI to a future phase.

2. **Background sync thread lifetime**
   - What we know: `std::thread::spawn` returns a `JoinHandle` that is dropped if not stored
   - What's unclear: Thread will keep running (daemon-style) even after handle is dropped on stable Rust. Process exit terminates it.
   - Recommendation: Drop the `JoinHandle` (let the thread run detached). This is the standard pattern for long-lived background threads in single-process desktop apps.

3. **Slint `invoke_from_event_loop` availability**
   - What we know: `slint::invoke_from_event_loop` exists in Slint 1.x
   - What's unclear: Whether the function requires Slint's backend to be initialized before the call (it does — only callable after `DashboardWindow::new()`)
   - Recommendation: Spawn background thread only after `window` is created. Confirmed safe pattern.

4. **Store slug for projection before config loads**
   - What we know: `project_snapshot(snapshot, store_slug, now)` requires `store_slug: &str`
   - What's unclear: When config is absent (NoopClient path), `store_slug` is `""` — projection emits empty URLs, Slint hides menu items for empty URLs (Phase 11 decision confirms this)
   - Recommendation: Pass `""` as store_slug when no config; no code change needed in projection layer.

---

## Validation Architecture

> `workflow.nyquist_validation` is not set in `.planning/config.json` — treating as enabled.

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in (`cargo test`) |
| Config file | None — workspace-level `cargo test` |
| Quick run command | `cargo test -p app live_client` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements → Test Map
This is an integration phase with no mapped requirement IDs. The functional requirements serviced are: DATA-01 through DATA-07 (all previously mapped to Phase 2, now actually wired in Phase 12).

| Behavior | Test Type | Automated Command |
|----------|-----------|-------------------|
| `LiveClient::fetch_card_snapshots()` returns snapshots from Repository | unit | `cargo test -p app live_client::tests::fetch_returns_snapshots` |
| `LiveClient` maps `RecipientSnapshot` → `RecipientCardSnapshot` correctly | unit | `cargo test -p app live_client::tests::snapshot_mapping` |
| `load_config()` returns `None` for missing file | unit | `cargo test -p app config::tests::missing_config` |
| `load_config()` deserializes valid TOML | unit | `cargo test -p app config::tests::valid_config_load` |
| `find_gh()` finds binary at known path | unit | `cargo test -p app live_client::tests::gh_discovery` |
| `GhCliProjectClient::fetch_rows` parses gh output to `GithubProjectRow` | unit (with mock subprocess output) | `cargo test -p app live_client::tests::gh_parse` |
| NoopClient path: main.rs compiles and runs without config | integration | `cargo build -p app` |

### Wave 0 Gaps
- [ ] `crates/app/src/live_client.rs` — new file, test module to be created in Wave 0
- [ ] `crates/app/src/config.rs` — new file, test module to be created in Wave 0

---

## Sources

### Primary (HIGH confidence)
- Direct codebase inspection — `crates/app/src/service_client.rs`, `bootstrap.rs`, `main.rs`
- Direct codebase inspection — `crates/service/src/api/recipients.rs`, `items.rs`, `sync/merge.rs`
- Direct codebase inspection — `crates/integrations/src/github/project_client.rs`, `project_mapping.rs`
- Direct codebase inspection — `crates/service/src/security/windows_credential_manager.rs`
- `gh version 2.88.1` — confirmed installed at `C:/Program Files/GitHub CLI/gh.exe`
- `gh api graphql --help` output — confirmed `-f query=...` `-F variable=...` syntax
- `gh api graphql -f query='{ viewer { login } }'` — confirmed authentication working

### Secondary (MEDIUM confidence)
- Workspace `Cargo.lock` — confirmed `toml` 0.9.12, `serde_json` 1.0.149, `dirs` 5.0.1, `ureq` 2.12.1
- Slint `invoke_from_event_loop` — documented in Slint 1.x API for cross-thread UI updates

### Tertiary (LOW confidence)
- GitHub Projects v2 GraphQL schema — query structure based on `gh` help + known Projects v2 API shape. Exact field names (`ProjectV2ItemFieldTextValue`, etc.) should be verified against a live query before implementing `GhCliProjectClient`.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all crates confirmed in workspace lock file; `gh` confirmed installed and authenticated
- Architecture: HIGH — service layer fully inspected; `Arc<Mutex>` + `std::thread` pattern is stdlib, no unknowns
- Pitfalls: HIGH — threading constraints verified against Rust type system; Slint cross-thread pattern is documented
- GitHub Projects GraphQL schema: MEDIUM — structure is standard Projects v2 API, but exact union type names should be tested against a live query

**Research date:** 2026-03-20
**Valid until:** 2026-04-20 (stable domain — stdlib, existing crates, installed tooling)
