# Phase 16.1: Discord Username Inline Editing, Avatar Fetching & Caching - Research

**Researched:** 2026-03-23
**Domain:** Discord API, Slint image loading, WCM credential storage, GH write-back, SQLite schema migration, popover UI extension
**Confidence:** HIGH (all key technical decisions grounded in existing codebase and verified API behavior)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Discord username inline editing**
- Discord username appears in the summary popover as display text with a pencil icon (Unicode glyph) for edit affordance
- Click pencil to enter edit mode (TextInput field, same pattern as Vision Rx)
- On blur or Enter: save triggers GH Project write-back via `update_field_text` (existing infrastructure)
- Shows "Saved" indicator after successful write-back (existing popover pattern)
- After save: Discord API call to resolve new username to discord_user_id
- User ID resolution triggers avatar re-fetch for the new user

**Vision Rx edit affordance update**
- Retroactive change: Vision Rx OD and OS fields also get the pencil icon for edit affordance (currently click-to-edit with no visible icon)
- Same Unicode pencil glyph as Discord username

**Discord bot token storage**
- Discord bot token stored in Windows Credential Manager (WCM) via keyring — same pattern as Shopify token (Phase 12.1)
- New section in Settings modal for Discord bot token entry, validation, and clear
- When no token configured: avatars show initials circle, Settings shows subtle hint that a Discord bot token enables avatar images

**Discord user ID resolution**
- When Discord username is edited and saved, call Discord API (Get User by username) using the bot token from WCM
- Updates discord_user_id on the recipient
- If resolution fails (user not found, API error): keep old user_id, show error toast

**Avatar image fetching strategy**
- Primary approach (research needed): Upload Discord avatar image to GH Project draft issue body (GitHub hosts via CDN). During GH sync, read image URL from draft body. This makes GitHub the avatar cache — no Discord API needed at runtime for display.
- Fallback approach: Fetch avatar from Discord CDN directly (`cdn.discordapp.com/avatars/{user_id}/{hash}.png`), cache to local disk.
- Research must determine if GH Project V2 draft items support programmatic body/image upload via GraphQL.

**Avatar caching (fallback path)**
- Cache location: `%APPDATA%/WITwhat/avatars/{discord_user_id}.png`
- Refresh triggers: on Discord username change (immediate re-fetch) + weekly staleness check during sync
- When no bot token configured: no fetching attempted, initials circle displayed

**Avatar display on cards**
- Initials circle is the default/fallback (no change from current behavior)
- When a cached avatar image exists: instant swap from initials to real image (no animation/transition)
- Avatar circle remains 26px with the Purpose-colored ring from Phase 16

**Popover layout redesign**
- New field order: Purpose, Email, Discord Username, Products in Possession, Last Activity, Last Status Update, Vision Rx
- Email: from Shopify customer data, display only — removed from card face, lives only in popover
- Products in Possession: renamed from "Items in Possession" / "Recipient Products (GH)"
- Last Activity: replaces both "Last Shipment Date" and "Last Received"
- Last Activity format: `{Status} - {date}` then product list from relevant card

**Last Activity field logic**
- Shows the most recent card for this recipient that reached one of: Shipped, Delivered, Returning, Returned
- Status label mapping: `InTransit` -> "Shipped", `Delivered` -> "Delivered", `ReturnInTransit` -> "Returning", `Returned` -> "Returned"
- "In Transit" label is explicitly forbidden

**Email removal from card face**
- Card face contact-secondary: show Discord username when available, "No contact info" otherwise (no email fallback)

**DATA-FLOW.md updates**
- Document avatar_url or avatar_cache_path as a new derived field
- Document discord_bot_token as a new credential in WCM
- Document popover field changes and the chosen avatar caching approach

### Claude's Discretion
- Exact Unicode glyph choice for pencil icon (must render in Slint — verify, as vertical ellipsis does NOT render)
- Discord API error handling details and retry strategy
- Exact GH Project draft body image upload approach (research-dependent)
- Avatar image format and size optimization
- Exact "Last Activity" date formatting

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope.
</user_constraints>

---

## Summary

Phase 16.1 involves four distinct capability areas: (1) inline editing of Discord username in the popover with GH Project write-back, (2) Discord user ID resolution from username via Discord REST API with a bot token, (3) avatar image fetching from Discord CDN and local disk caching, and (4) popover layout redesign. The Slint UI patterns, keyring/WCM storage, and GH write-back infrastructure all already exist in the codebase — this phase extends them. The most significant architectural decision is the avatar caching strategy.

**Critical research finding:** The primary approach (GH Project draft body as avatar CDN) is NOT viable. The GitHub GraphQL API for Projects V2 does not expose a mutation that embeds uploaded images into a draft issue body and returns a GitHub CDN URL. The `updateProjectV2ItemFieldValue` mutation only handles structured field types (text, single-select, date, number, iteration). Image hosting via GitHub requires uploading through the repository Issues UI or the Content API, which is not scoped to GH Project items. The fallback approach — local disk cache at `%APPDATA%/WITwhat/avatars/{discord_user_id}.png` — is the only viable path and is already fully specified in CONTEXT.md.

**Second critical finding:** Discord provides no public API endpoint to look up users by username globally. The bot token can call `GET /users/{user_id}` to fetch by ID, and `GET /guilds/{guild_id}/members/search?query={username}` to search within a shared guild. The CONTEXT.md decision "call Discord API (Get User by username)" must be implemented as a guild member search (requires a known guild ID) rather than a global username lookup.

**Primary recommendation:** Use local disk cache exclusively for avatar storage. For username-to-user-ID resolution, use `GET /guilds/{guild_id}/members/search` against the team's Discord server guild ID (which must be stored in WCM or config). If guild ID is unavailable, the discord_user_id field on the GH Project row is the only other source — the GH Project should have a `discord_user_id` column that stores the resolved ID persistently.

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `keyring` | 3.x (windows-native feature) | WCM FFI for Discord bot token | Already in service Cargo.toml; WindowsCredentialManagerStore pattern established in Phase 12.1 |
| `ureq` | 2.x (json feature) | HTTP calls to Discord REST API and Discord CDN | Already in app and integrations Cargo.toml; used for Shopify API calls |
| `slint` | 1.x | UI rendering; `Image::load_from_path()` for avatar display | Project standard; dynamic image loading via `Image::load_from_path` works for local files |
| `rusqlite` | 0.32 (bundled) | SQLite schema migration for new recipient columns | Already in service Cargo.toml; refinery for migrations |
| `refinery` | 0.8 (rusqlite feature) | SQL migration runner; next migration is V003 | Already established pattern; just add V003 SQL file |
| `dirs` | 5.x | Resolve `%APPDATA%` for avatar cache directory path | Already in app Cargo.toml; used for db_path() |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `serde_json` | 1.x | Parse Discord API JSON responses | Already available in both integrations and app |
| `std::fs` | stdlib | Write and read avatar PNG files to disk | No external crate needed |

### No New Dependencies Required
All required capabilities are covered by libraries already in the dependency graph. No new crates need to be added to any Cargo.toml.

**Installation:** No new packages — all dependencies already present.

---

## Architecture Patterns

### Recommended Project Structure Extensions

```
crates/
├── integrations/src/discord/          # NEW — Discord API client
│   ├── mod.rs                         # pub mod declarations
│   ├── user_client.rs                 # Trait + GuildMemberSearchClient
│   └── avatar_fetcher.rs              # Fetch avatar bytes from CDN, write to disk
├── service/src/db/migrations/
│   └── V003__add_discord_avatar_fields.sql  # NEW — avatar_hash, discord_user_id on recipients
├── service/src/security/
│   └── windows_credential_manager.rs  # No change — reuse existing
└── app/
    ├── src/config.rs                  # Add discord_bot_secret_ref field
    ├── src/main.rs                    # Wire Discord save callback, avatar load
    └── ui/
        ├── card.slint                 # Popover redesign, pencil icons, avatar Image
        └── settings-modal.slint       # Discord bot token section
```

### Pattern 1: GH Project Write-Back (Established)

**What:** Fire-and-forget background thread calls `write_rx_to_gh` pattern.
**When to use:** After Discord username save — same pattern as Vision Rx OD/OS.
**Example:**
```rust
// Source: crates/app/src/main.rs lines 154-188 (write_rx_to_gh pattern)
fn write_discord_username_to_gh(
    store: &service::db::sqlite::SqliteStore,
    config: &app::config::AppConfig,
    recipient_id: &str,
    value: &str,
) -> Result<(), String> {
    // identical structure to write_rx_to_gh
    // field_name = "discord_username" (exact GH Project column name TBD)
}
```

### Pattern 2: WCM Credential Storage (Established — Phase 12.1)

**What:** Use `WindowsCredentialManagerStore` with `keyring::Entry::new("witwhat", secret_ref)`.
**When to use:** Discord bot token storage, retrieval, and deletion.
**Example:**
```rust
// Source: crates/service/src/security/windows_credential_manager.rs
// secret_ref = "wincred:discord/bot-token"
let store = WindowsCredentialManagerStore::new();
store.set("wincred:discord/bot-token", &token_value)?;
let token = store.get("wincred:discord/bot-token")?;
store.delete("wincred:discord/bot-token")?;
```

### Pattern 3: Discord REST API — User Fetch by ID

**What:** `GET https://discord.com/api/v10/users/{user_id}` with `Authorization: Bot {token}`.
**When to use:** After guild member search resolves a user_id; also to fetch avatar hash for a known user_id.
**Response fields:** `id`, `username`, `global_name`, `avatar` (hash string, nullable).
**Example:**
```rust
// Source: Discord REST API docs (docs.discord.com/developers/resources/user)
let url = format!("https://discord.com/api/v10/users/{}", user_id);
let resp = ureq::get(&url)
    .set("Authorization", &format!("Bot {}", bot_token))
    .call()?
    .into_json::<serde_json::Value>()?;
let avatar_hash = resp["avatar"].as_str(); // nullable
```

### Pattern 4: Discord REST API — Guild Member Search (Username Resolution)

**What:** `GET /guilds/{guild_id}/members/search?query={username}&limit=1` returns members whose username starts with the query string.
**Critical constraint:** Requires the guild (server) ID — a constant for the team's Discord server. This must be stored in config or as a hardcoded constant.
**When to use:** After Discord username is edited and saved, to resolve to a discord_user_id.
**Limitation:** Only matches usernames that start with the query string, not exact-match substring search. For exact match, filter the returned results by exact `username` comparison.
**Example:**
```rust
// Source: Discord REST API docs (docs.discord.com/developers/resources/guild)
let url = format!(
    "https://discord.com/api/v10/guilds/{}/members/search?query={}&limit=10",
    guild_id, encoded_username
);
let resp = ureq::get(&url)
    .set("Authorization", &format!("Bot {}", bot_token))
    .call()?
    .into_json::<serde_json::Value>()?;
// resp is an array; each element has .user.id and .user.username
// Filter for exact username match
```

### Pattern 5: Avatar CDN URL Construction

**What:** Discord avatar URL is `https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=256`.
**When to use:** After fetching user object and having a non-null avatar hash.
**PNG fallback:** Always use `.png` extension (not `.webp`) for maximum compatibility and Slint load support.
**Example:**
```rust
// Source: Discord REST API image formatting docs
fn avatar_cdn_url(user_id: &str, avatar_hash: &str) -> String {
    format!(
        "https://cdn.discordapp.com/avatars/{}/{}.png?size=256",
        user_id, avatar_hash
    )
}
```

### Pattern 6: Slint Dynamic Image Loading

**What:** `slint::Image::load_from_path(path)` for runtime file loading. The image property on a Slint component must be `in property <image>`.
**When to use:** Loading cached avatar PNG files into the avatar circle.
**Critical behavior:** `load_from_path` does NOT auto-invalidate cache if the file changes on disk (known Slint issue #4599). After writing a new avatar file, force reload by setting the property to `Image::default()` first, then setting to the loaded image.
**Example:**
```rust
// Source: Slint API docs + issue #4599 workaround
use slint::Image;
use std::path::Path;

fn load_avatar_image(cache_path: &Path) -> Option<Image> {
    if cache_path.exists() {
        Image::load_from_path(cache_path).ok()
    } else {
        None
    }
}
// In Slint: `in property <image> avatar-image: @image-url("");`
// Set from Rust: card.set_avatar_image(loaded_image);
```

### Pattern 7: SQLite Migration for New Fields

**What:** Add `avatar_hash` and update `discord_user_id` storage to recipients table via V003 migration.
**When to use:** Persisting the resolved discord_user_id (written back after resolution) and avatar_hash for staleness comparison.
**Example:**
```sql
-- V003__add_discord_avatar_fields.sql
ALTER TABLE recipients ADD COLUMN discord_user_id TEXT;
ALTER TABLE recipients ADD COLUMN avatar_hash TEXT;
-- discord_username already exists from V001; discord_user_id is new here
```
**Note:** Verify which columns are already in V001/V002. The `RecipientRow` struct in sqlite.rs has `discord_username` and `discord_user_id` fields already — confirm they are in the existing schema before adding them in V003.

### Pattern 8: Popover Edit State on RecipientCard

**What:** Inline edit state (editing-discord-username, discord-username-draft) MUST live on `RecipientCard` component, not inside `PopupWindow`.
**Why:** PopupWindow re-initializes on every show() call. Pattern established in Phase 16-03 for Vision Rx.
**Example:**
```slint
// Source: card.slint lines 86-93 (Vision Rx pattern)
in-out property <bool> editing-discord-username: false;
in-out property <string> discord-username-draft: "";
callback save-discord-username(string);
```

### Anti-Patterns to Avoid

- **Storing edit state inside PopupWindow:** PopupWindow re-inits on show(). State resets unexpectedly. Always put mutable popover state on the outer RecipientCard.
- **Using @image-url() for dynamic paths:** @image-url() requires compile-time paths. Use `Image::load_from_path()` from Rust for runtime paths.
- **Assuming email fallback stays on card face:** email fallback is being removed. `contact_secondary()` function signature changes — it must drop the email parameter.
- **Using `\u{22EE}` (vertical ellipsis) for pencil icon:** Known non-rendering glyph in Slint (per MEMORY.md). Use `\u{270F}` (PENCIL) and test before implementation.
- **Global username lookup via Discord API:** No such endpoint exists. Must use guild member search with a known guild ID.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Credential storage | Custom file encryption | `WindowsCredentialManagerStore` + `keyring v3` | WCM FFI already implemented and tested in Phase 12.1 |
| HTTP calls | Raw TCP sockets | `ureq` (already in Cargo.toml) | Handles redirects, timeouts, JSON deserialization |
| Avatar disk cache directory | Custom path resolver | `dirs::config_dir()` + `std::fs::create_dir_all` | Already used for db_path and config.toml; consistent with existing pattern |
| Slint image display | HTML canvas or custom render | `slint::Image::load_from_path` + `in property <image>` | Built-in Slint image type handles PNG rendering in clipped circles |
| GH Project write-back | New HTTP client | Existing `GhCliProjectClient::update_field_text` | Already handles auth, error mapping, and GraphQL mutation |
| SQLite schema update | Manual ALTER at runtime | `refinery` migration file V003 | Already used for V001/V002; auto-runs on startup |

---

## Common Pitfalls

### Pitfall 1: RecipientRow Already Has discord_user_id

**What goes wrong:** V003 migration adds `discord_user_id` to recipients table but it already exists from V001 (confirmed: `RecipientRow` struct has `discord_user_id: Option<String>` and the initial schema likely included it). SQLite will panic on `ALTER TABLE ADD COLUMN` if the column exists.
**Why it happens:** The struct has the field but the V001/V002 migrations may or may not include it.
**How to avoid:** Read V001 migration SQL before writing V003 to confirm which columns already exist. Only add columns that are genuinely missing.
**Warning signs:** `rusqlite` error at startup: "duplicate column name".

### Pitfall 2: Discord CDN URL Without Avatar Hash

**What goes wrong:** Some users have `avatar: null` in the Discord user object (no custom avatar set). Constructing a CDN URL with a null hash produces a 404. Must fall back to initials.
**How to avoid:** Check `avatar_hash.is_some()` before fetching. If null, skip fetch and use initials circle.

### Pitfall 3: Slint Image Cache Does Not Invalidate

**What goes wrong:** After re-fetching and overwriting an avatar file on disk, `Image::load_from_path(same_path)` returns the cached (old) image without re-reading the file.
**How to avoid:** Set the property to `Image::default()` on the Slint event loop before setting it to the newly loaded image. This forces cache invalidation.

### Pitfall 4: Guild ID Not Available at Runtime

**What goes wrong:** Guild member search requires a guild ID. If not configured, username-to-user-ID resolution cannot proceed.
**How to avoid:** The guild ID for the team's Discord server should be a constant in the codebase (it's a fixed server, not user-configurable) OR stored in `AppConfig`. Research the actual guild ID and hardcode it as a constant in the Discord client module. The guild ID is a static, known value for `BigscreenVR/beyond-outgoing`'s team server.
**Warning signs:** HTTP 403 or "Unknown Guild" error from Discord API.

### Pitfall 5: contact_secondary Email Fallback Tests Break

**What goes wrong:** `contact_secondary()` in `external.rs` currently accepts `email: Option<&str>` and falls back to email when no Discord username. After this phase, email is removed from card face. The function signature changes and existing unit tests will fail.
**How to avoid:** Update `contact_secondary()` to remove the email parameter and update all call sites and tests. Existing tests `contact_secondary_falls_back_to_email_when_no_discord` and `contact_secondary_discord_takes_priority_over_email` will need to be removed or replaced.

### Pitfall 6: Settings Modal Height Expansion

**What goes wrong:** The settings modal has hardcoded height `440px` and `y: 390px` for the button row. Adding a Discord bot token section (approximately 120px of vertical space) will overflow without adjusting the modal height and button row position.
**How to avoid:** Increase modal height from 440px to ~580px and adjust `y:` offsets for the button row accordingly. Use existing absolute-positioned layout arithmetic.

### Pitfall 7: Popover Sections Exceed max-height

**What goes wrong:** Adding Email and Discord Username sections to the existing popover increases total content height. The popover uses `max-height: 420px` with a `Flickable`. This is acceptable — Flickable handles scrolling — but verify the Flickable height binding is set correctly so scrolling actually works.
**How to avoid:** Confirm `Flickable { height: parent.height - 2 * padding }` is set (or equivalent). If Flickable has no explicit height, scrolling will not activate.

### Pitfall 8: Unicode Pencil Glyph \u{270F} Rendering

**What goes wrong:** `\u{270F}` (PENCIL) may or may not render in Slint depending on the system font. The known non-rendering glyph is `\u{22EE}` (vertical ellipsis). `\u{270F}` needs empirical verification.
**How to avoid:** The first task of the first wave should be a single-line verification: add `text: "\u{270F}"` to a test Text element and confirm rendering. If it fails, use `\u{270E}` (LOWER RIGHT PENCIL) or the ASCII fallback `[e]` per UI-SPEC.

---

## Code Examples

### GH Write-Back for Discord Username (Extension of Existing Pattern)

```rust
// Source: crates/app/src/main.rs write_rx_to_gh (lines 154-188) — extend this pattern
fn write_discord_username_to_gh(
    store: &service::db::sqlite::SqliteStore,
    config: &app::config::AppConfig,
    recipient_id: &str,
    value: &str,
) -> Result<(), String> {
    use integrations::github::project_client::GithubProjectClient as _;
    let row = store.read_recipient(recipient_id)
        .map_err(|e| format!("SQLite read: {:?}", e))?
        .ok_or_else(|| format!("Recipient '{}' not found", recipient_id))?;
    let item_id = row.github_item_id
        .ok_or_else(|| "No github_item_id".to_string())?;
    let project_id = &config.github_project_node_id;
    let gh_client = integrations::github::gh_cli_client::GhCliProjectClient::new()
        .map_err(|e| format!("gh CLI: {:?}", e))?;
    let field_id = gh_client.fetch_field_id(project_id, "discord_username")
        .map_err(|e| format!("fetch_field_id: {:?}", e))?
        .ok_or_else(|| "Field 'discord_username' not found in GH Project".to_string())?;
    gh_client.update_field_text(project_id, &item_id, &field_id, value)
        .map_err(|e| format!("update_field_text: {:?}", e))?;
    Ok(())
}
```

### Avatar Fetch and Cache Write

```rust
// Source: pattern derived from existing ureq usage (crates/integrations/src/shopify/http_client.rs)
fn fetch_and_cache_avatar(
    bot_token: &str,
    user_id: &str,
    avatar_hash: &str,
) -> Result<std::path::PathBuf, String> {
    let cache_dir = dirs::config_dir()
        .ok_or("no config dir")?
        .join("WITwhat")
        .join("avatars");
    std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
    let dest = cache_dir.join(format!("{}.png", user_id));

    let url = format!(
        "https://cdn.discordapp.com/avatars/{}/{}.png?size=256",
        user_id, avatar_hash
    );
    let resp = ureq::get(&url)
        .set("Authorization", &format!("Bot {}", bot_token))
        .call()
        .map_err(|e| format!("avatar fetch failed: {}", e))?;
    let mut bytes = Vec::new();
    resp.into_reader().read_to_end(&mut bytes)
        .map_err(|e| format!("read avatar bytes: {}", e))?;
    std::fs::write(&dest, &bytes).map_err(|e| format!("write avatar: {}", e))?;
    Ok(dest)
}
```

### Slint Discord Username Display + Edit Block (Extension of Vision Rx Pattern)

```slint
// Source: card.slint Vision Rx OD pattern (lines 940-982) — extend for Discord username
// Add to RecipientCard in-out properties:
//   in-out property <bool> editing-discord-username: false;
//   in-out property <string> discord-username-draft: "";
//   callback save-discord-username(string);

// Display row:
if !root.editing-discord-username : HorizontalLayout {
    spacing: 4px;
    Text {
        text: root.discord-username-draft != "" ? root.discord-username-draft : "\u{2014}";
        font-size: Typography.size-sm;
        color: Colors.text-muted;
        vertical-alignment: center;
    }
    Text {
        text: "\u{270F}";  // PENCIL — verify renders in Slint
        font-size: Typography.size-xs;
        color: pencil-touch.has-hover ? Colors.text-secondary : Colors.text-muted;
        vertical-alignment: center;
    }
    pencil-touch := TouchArea {
        mouse-cursor: pointer;
        clicked => { root.editing-discord-username = true; }
    }
}

// Edit mode:
if root.editing-discord-username : Rectangle {
    height: 26px;
    border-radius: 4px;
    background: Colors.background;
    border-width: 1px;
    border-color: Colors.accent;
    discord-input := TextInput {
        x: 6px; y: 4px;
        width: parent.width - 12px;
        height: 18px;
        text <=> root.discord-username-draft;
        font-size: Typography.size-sm;
        color: Colors.text-primary;
        // On accept (Enter) or focus-lost: call save-discord-username
    }
}
```

### Last Activity Logic (Rust)

```rust
// Source: derived from RecipientCardSnapshot structure in service_client.rs
// Given a list of RecipientCardSnapshots for a recipient, find Last Activity
fn compute_last_activity(
    snapshots: &[RecipientCardSnapshot],
) -> Option<(String, String, Vec<String>)> {
    let qualifying_statuses = ["In Transit", "Delivered", "Return In Transit", "Returned"];
    let status_display = |s: &str| match s {
        "In Transit" => "Shipped",
        "Return In Transit" => "Returning",
        other => other,
    };
    snapshots
        .iter()
        .filter(|s| s.shipment_status.as_deref()
            .map(|st| qualifying_statuses.contains(&st))
            .unwrap_or(false))
        .max_by_key(|s| s.shipment_status_date.as_deref().unwrap_or(""))
        .map(|s| {
            let status = status_display(s.shipment_status.as_deref().unwrap_or(""));
            let date = s.shipment_status_date.clone().unwrap_or_default();
            (status.to_string(), date, s.product_names.clone())
        })
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Initials circle only for avatar | Runtime `Image::load_from_path()` for cached PNG | This phase | Avatar circle can display real Discord profile photos |
| Email on card face as contact fallback | Email in popover only; "No contact info" on card face | This phase | Cleaner card face; email visible in detail view only |
| No pencil affordance on Vision Rx | Pencil icon `\u{270F}` on all editable popover fields | This phase | Consistent editability signaling across all fields |
| discord_user_id stored only from GH Project sync | Also resolved from Discord API after username edit | This phase | User ID stays current when username is changed |

**Deprecated/outdated:**
- `contact_secondary()` email fallback parameter: remove the `email: Option<&str>` parameter entirely. The function becomes `fn contact_secondary(discord_username: Option<&str>) -> String`.
- "Items in Possession" popover section label: replaced by "Products in Possession".
- "Last Shipment Date" and "Last Received" popover sections: merged into "Last Activity".

---

## Open Questions

1. **discord_user_id column in SQLite (V001 vs V003)**
   - What we know: `RecipientRow` struct has `discord_user_id: Option<String>`. V002 migration only adds `purpose`, `purpose_color`, `vision_rx_od/os`, `product_names`, `product_shopify_urls`.
   - What's unclear: Does V001 include `discord_user_id` and `discord_username` in the initial schema? The code has these fields but V001 SQL was not read in research.
   - Recommendation: **Read V001__initial_schema.sql before writing V003** to avoid duplicate column error at startup.

2. **avatar_hash column necessity**
   - What we know: Avatar staleness check on username change can be done by re-fetching unconditionally.
   - What's unclear: Whether a weekly staleness check needs the stored hash to compare against the current Discord-side hash.
   - Recommendation: Store `avatar_hash` in SQLite for staleness comparison. Add to V003 migration.

3. **Guild ID source for Discord member search**
   - What we know: Discord guild member search requires a guild ID. The team has a known Discord server.
   - What's unclear: Should the guild ID be hardcoded as a constant, stored in WCM, or added to AppConfig?
   - Recommendation: Hardcode as a constant in the Discord client module (e.g., `const DISCORD_GUILD_ID: &str = "..."`) since it is a fixed, known value for this deployment. If it varies, add an optional `discord_guild_id` field to AppConfig.

4. **AppConfig discord_bot_secret_ref field**
   - What we know: `AppConfig` uses `shopify_secret_ref: String` to store the WCM reference key for the Shopify token.
   - What's unclear: Whether `discord_bot_secret_ref` should be optional in AppConfig (app works without it — initials fallback) or mandatory.
   - Recommendation: Add `discord_bot_secret_ref: Option<String>` (with `#[serde(default)]`) to `AppConfig`. `None` means no Discord bot token configured.

5. **discord_username GH Project column name**
   - What we know: DATA-FLOW.md says the column is "discord_username" (to be added to GH Project). `write_rx_to_gh` uses the exact GH Project column name as `field_name`.
   - What's unclear: What is the exact column name in the actual GH Project schema today?
   - Recommendation: Confirm the exact column name in the live GH Project before coding the write-back call.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[test]` (no external test framework) |
| Config file | `Cargo.toml` per crate (workspace) |
| Quick run command | `cargo test -p app -p integrations -p service -p wit_core 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements -> Test Map

| Behavior | Test Type | Automated Command | Notes |
|----------|-----------|-------------------|-------|
| `contact_secondary` removes email fallback | unit | `cargo test -p app contact_secondary` | Existing tests will need updating — email tests become invalid |
| Discord username GH write-back function signature | unit | `cargo test -p app write_discord_username` | New function; test SQL/field lookup path |
| Avatar CDN URL construction | unit | `cargo test -p integrations avatar_cdn_url` | Simple string formatting test |
| Guild member search parsing | unit | `cargo test -p integrations parse_guild_member_search` | Parse mock JSON response |
| `compute_last_activity` status label mapping | unit | `cargo test -p app compute_last_activity` | Verify "In Transit" -> "Shipped", forbidden labels absent |
| WCM Discord token set/get/delete | unit (mock) | `cargo test -p service credential_loader` | Existing mock pattern; add Discord token case |
| SQLite V003 migration runs clean | integration | `cargo test -p service sqlite` | Migration test via in-memory DB |
| Avatar file write and reload | unit | `cargo test -p app fetch_and_cache_avatar` | Test with mock HTTP or skip CDN in unit test |

### Sampling Rate
- **Per task commit:** `cargo test -p app -p integrations 2>&1`
- **Per wave merge:** `cargo test --workspace 2>&1`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] No new test files required — extend existing test modules inline
- [ ] `V003__add_discord_avatar_fields.sql` — needed before any SQLite work begins; determine exact columns after reading V001

*(Existing test infrastructure covers all phase requirements through inline `#[cfg(test)]` modules.)*

---

## Sources

### Primary (HIGH confidence)
- Codebase direct read: `crates/app/src/main.rs` lines 154-188 — `write_rx_to_gh` pattern (GH write-back)
- Codebase direct read: `crates/service/src/security/windows_credential_manager.rs` — WCM FFI pattern
- Codebase direct read: `crates/app/ui/card.slint` lines 836-1040 — popover current structure and Vision Rx edit pattern
- Codebase direct read: `crates/app/ui/settings-modal.slint` — Shopify token section pattern (model for Discord section)
- Codebase direct read: `crates/service/src/db/migrations/V002__add_recipient_columns.sql` — migration pattern and existing columns
- Codebase direct read: `crates/app/src/service_client.rs` — `RecipientCardSnapshot` fields
- Codebase direct read: `crates/service/src/db/sqlite.rs` — `RecipientRow` struct

### Secondary (MEDIUM confidence)
- Discord Developer Docs (docs.discord.com/developers/resources/user): User object fields, `GET /users/{user.id}`, avatar hash format
- Discord Developer Docs (docs.discord.com/developers/resources/guild): `GET /guilds/{guild.id}/members/search` endpoint behavior
- Slint GitHub issue #4599: `Image::load_from_path` cache invalidation behavior — confirmed not auto-invalidating

### Tertiary (LOW confidence)
- WebSearch: GitHub Projects V2 GraphQL draft body image upload — **conclusion: not viable** (no mutation supports embedded image upload returning CDN URL). Lack of official documentation for this use case confirms the fallback path is the only option.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all libraries already in Cargo.toml; no new dependencies
- Architecture: HIGH — all patterns established in Phase 12.1 and Phase 16-03; extensions of working code
- Discord API: MEDIUM — verified via official docs; guild ID requirement is a concrete constraint requiring a known value
- GH draft body approach: HIGH (confidence it does NOT work) — absence confirmed across docs and WebSearch
- Slint image loading: MEDIUM — `load_from_path` documented; cache invalidation behavior confirmed via GitHub issue

**Research date:** 2026-03-23
**Valid until:** 2026-04-23 (stable stack; Discord API behavior unlikely to change)
