# Phase 17: GH Issues Client and Product Catalog - Research

**Researched:** 2026-03-25 (updated from 2026-03-24 — subissues API findings added)
**Domain:** GitHub Issues REST (via `gh` CLI), SQLite product schema, Slint product catalog UI, GitHub Subissues API
**Confidence:** HIGH

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**GH Issue Body Format**
- JSON code block in the issue body, parsed with serde_json
- `schema_version: 1` field for forward compatibility
- Target repo: `BigscreenVR/beyond-outgoing` (hardcoded, matching DATA-FLOW.md)

**Two issue types (with native subissue hierarchy):**
1. `ww-product` (parent product type)
   - Issue title = product name
   - Issue label = `ww-product`
   - Body JSON: `product_id`, `image_url`, `shopify_product_url`
   - **No `unit_refs[]` in body** — child units tracked via GitHub's native subissues feature
   - Issue comments/timeline used for product-level notes
   - Product units appear as subissues in GitHub UI automatically
2. `ww-product-unit` (individual serialized unit — **subissue of parent `ww-product`**)
   - Issue title = serial ID (e.g., "HMD-001")
   - Issue label = `ww-product-unit`
   - Body JSON: `serial_id`, `product_id`, `state`, `assigned_card_id`
   - **No `parent_ref` in body** — parent relationship expressed via GitHub's native subissue hierarchy
   - **Each unit gets its own GH Issue**
   - **Created as a subissue** of the parent `ww-product` issue via the Subissues REST API
3. `ww-card` — Phase 18 (not this phase)

**Subissues rule:** Every `ww-product-unit` issue MUST be created as a subissue of its parent `ww-product` issue. This replaces the previous `unit_refs[]` / `parent_ref` cross-linking approach. GhIssuesClient must call the Subissues REST API after creating each unit issue.

**GH Issues Transport**
- Use `gh` CLI (consistent with existing `GhCliProjectClient`)
- Commands: `gh issue create`, `gh issue edit`, `gh issue list`, `gh api` (for subissues)
- Auth handled by gh's own login — no new GitHub auth path
- JSON output mode (`--json`) for structured parsing
- **Subissues:** `gh` CLI does NOT support subissues natively (`gh issue create` has no `--parent` flag). Use `gh api repos/{owner}/{repo}/issues/{parent_number}/sub_issues` via the REST Subissues API.

**Product Catalog UI**
- Product catalog lives within the existing "By Product Shipped" discovery tab — no new tab
- Option grid shows product tiles: image + name + recipient count
- Tiles have ellipsis menu (same pattern as card ellipsis menus) with: Set Image, View on Shopify, Product Detail, Archive
- Clicking a product tile filters cards by that product
- No serial/non-serial distinction on the grid — one square per product type
- Products without a custom image auto-fetch the first Shopify product image when linked

**Product Entity Model**
- All products are assumed serializable — no `is_serializable` boolean toggle
- Product (parent) fields: `product_id`, `name`, `image_url`, `shopify_product_url`, `github_issue_number`
- Product Unit (child) fields: `serial_id`, `product_id`, `state`, `assigned_card_id`, `assigned_at`, `github_issue_number`
- **Parent↔child linking:** Uses GitHub's native subissues feature. No manual cross-link arrays in body JSON.
- **Sync:** List subissues of a `ww-product` issue via REST API to discover its units.

**Product Creation Flow**
- Products created manually via "Add Product" in catalog
- Create form: Name (required), Shopify URL (optional), image (optional)
- Shopify URL field auto-suggests unlinked Shopify order products
- GH Project parallel-array columns (`product_names`, `product_shopify_urls`) are WITwhat-owned columns — not yet created on GH Project; user will create when needed
- Existing products loaded from GH Issues (`ww-product` label)

**Product Add-to-Card Flow**
- User can add any product generically, select an existing serialized instance in stock, or click `+` to create a new serialized child instance inline
- Product assignment replaces freeform `item_summary` with structured `product_refs`

### Claude's Discretion
- Product detail panel layout and information density
- Image resize/compression strategy before upload
- Product-images branch structure (flat vs folder-per-product)
- How to handle product name edits (rename GH Issue title + update references)
- Exact Shopify URL auto-suggest implementation (when to fetch, caching)
- **Subissues API approach:** Whether to use REST (`gh api repos/...`) or GraphQL mutations — research recommends REST (simpler, no feature header needed as of 2026)

### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| CLOUD-03 | System can create GH Issues tagged `ww-product` for product catalog entries | `GhIssuesClient::create_product_issue` with `--label ww-product`, body as JSON code block, returns issue number |
| CLOUD-04 | System can update `ww-product` GH Issues when product data changes | `GhIssuesClient::edit_issue_body` with `--body-file` temp-file pattern; parse updated body back |
| PROD-01 | User can browse a standalone product list showing all hardware products | Extend `extract_product_tiles` / `ByProductShipped` option grid to use `ProductRow` from SQLite instead of card-derived names |
| PROD-02 | Product entities include product_id, name, image_url, and is_serializable flag | `ProductRow` struct in SQLite (schema already has `products` table in V001); design locks all products serializable |
| PROD-03 | Cards reference products via `product_refs` instead of freeform `item_summary` | Migration: add `product_refs` JSON column to `cards` table; wire `ProductRef` in `RecipientCardSnapshot`; projection.rs maps to item squares |
| PROD-04 | User can add serialized products nested under their parent product via the product add UI | Product add-to-card flow: product picker shows parent + nested in-stock units; backed by `product_units` table |
| PROD-05 | User can create new serialized product instances from a parent product | Inline `+` in product picker creates new `ww-product-unit` GH Issue + calls Subissues REST API to link as subissue + inserts into `product_units` table |
</phase_requirements>

---

## Summary

Phase 17 introduces the `GhIssuesClient` — a REST-over-`gh`-CLI client for GitHub Issues — and uses it to create the product catalog: both the cloud-of-record storage (GH Issues) and the local SQLite mirror. The codebase already has all the structural patterns needed: `GhCliProjectClient` for `gh` subprocess calls, `AvatarBranchClient` for the `--body-file` temp-file pattern required when body text is large, and `SqliteStore` for the WAL-mode SQLite layer. The products table and serial_instances table already exist in V001 migration; this phase activates them.

**Subissues change (2026-03-25 update):** The original design used `unit_refs[]` in the parent body and `parent_ref` in the child body for cross-linking. This is now replaced by GitHub's native Subissues feature. Every `ww-product-unit` issue is created as a subissue of its parent `ww-product` issue using the REST Subissues API (`POST repos/{owner}/{repo}/issues/{parent_number}/sub_issues`). The `gh` CLI does NOT have a native `--parent` flag for `gh issue create` — the subissue relationship must be established as a second call via `gh api` after the unit issue is created. This means creating a product unit is now a two-step atomic operation: (1) create the unit issue, (2) link it as a subissue. The body JSON for both issue types is simpler — no cross-link arrays needed.

The V004 migration no longer needs `unit_refs_json TEXT` on `products` or `parent_ref TEXT` on `serial_instances`. Both columns become unnecessary since GitHub tracks the relationship natively. The `github_issue_number INTEGER` column addition remains needed on both tables.

**Primary recommendation:** Follow the `AvatarBranchClient` / `GhCliProjectClient` patterns exactly for issue creation. Use `gh api` for the two-step subissue linking. Extend, do not reinvent.

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `gh` CLI | system (≥2.x) | All GitHub API calls (Issues create/edit/list/view, Subissues via `gh api`) | Already used for Project API; auth is shared; no token management needed |
| `rusqlite` | 0.32 (bundled) | SQLite CRUD for products, product_units tables | Already in service crate; WAL + Mutex pattern established |
| `refinery` | 0.8 | Embedded SQL migrations (V004+) | Already used; migrations live in `crates/service/src/db/migrations/` |
| `serde_json` | 1 | Serialize/deserialize GH Issue body JSON | Already a dependency in `integrations` and `app` crates |
| `base64` | 0.22 | Encode image bytes for product-images branch upload | Already used in `avatar_branch_client.rs` |
| `uuid` | 1 | Generate `product_id` UUIDs | Standard; needs to be added as a dependency if not present |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `open` | 5 | Open Shopify product URL in default browser | Already in `app` crate for "View on Shopify" action |
| `dirs` | 5 | Resolve `%APPDATA%` path for product image disk cache | Already a dependency |
| `image` (optional) | 0.25 | Resize/compress product images before upload | Only needed if implementing image resize; can defer |

**Version verification:** `uuid` is the only likely new dependency. Confirmed via registry: `uuid = "1"` with `features = ["v4"]` is current as of 2026.

**Installation (if `uuid` not already present in `integrations/Cargo.toml`):**
```bash
# Add to crates/integrations/Cargo.toml
uuid = { version = "1", features = ["v4"] }
```

---

## Architecture Patterns

### Recommended File Structure

```
crates/integrations/src/github/
├── gh_cli_client.rs            # Existing — GhCliProjectClient (GH Project API)
├── avatar_branch_client.rs     # Existing — orphan branch upload pattern (reuse for product images)
├── issues_client.rs            # NEW — GhIssuesClient (GH Issues create/edit/list + subissue link)
└── product_images_branch_client.rs  # NEW — product-images orphan branch upload (adapts AvatarBranchClient)

crates/service/src/db/
├── migrations/
│   ├── V001__initial_schema.sql     # Existing — has `products` and `serial_instances` tables
│   ├── V002__add_recipient_columns.sql
│   ├── V003__add_avatar_hash.sql
│   └── V004__products_schema_fix.sql  # NEW — reconcile products/units tables with current spec
├── sqlite.rs                    # Extend — add ProductRow, ProductUnitRow, CRUD methods

crates/app/src/
├── live_client.rs               # Extend — add product sync (fetch ww-product issues, list subissues for units)
└── dashboard/
    ├── discovery.rs             # Extend — extract_product_tiles uses ProductRow not card names
    └── projection.rs            # Extend — map ProductRef to item squares
```

### Pattern 1: GhIssuesClient (mirrors GhCliProjectClient)

**What:** A struct that shells out to `gh issue create/edit/list/view` with `--json` output for structured parsing. Uses `gh api` for subissue operations. Errors are classified as `Unauthorized` vs `Transport` matching the existing error type.

**When to use:** Any GitHub Issues CRUD or subissue operation. Never use direct HTTP — `gh` CLI handles auth.

**Example:**
```rust
// Source: modeled on crates/integrations/src/github/gh_cli_client.rs
pub struct GhIssuesClient {
    gh_path: PathBuf,
    owner: &'static str,
    repo: &'static str,
}

pub enum GhIssuesError {
    Unauthorized,
    NotFound,
    Transport(String),
}

pub struct CreatedIssue {
    pub number: i64,      // GH issue number (used as github_issue_number in SQLite)
    pub database_id: i64, // Numeric REST ID (used as sub_issue_id when linking subissues)
    pub url: String,
}

impl GhIssuesClient {
    pub fn new() -> Result<Self, GhIssuesError> {
        let gh_path = find_gh()
            .ok_or_else(|| GhIssuesError::Transport("gh binary not found".into()))?;
        Ok(Self { gh_path, owner: "BigscreenVR", repo: "beyond-outgoing" })
    }

    /// Create a new issue. Body written to temp file to avoid arg-length limits.
    /// Returns the new issue number, database_id, and url.
    pub fn create_issue(
        &self,
        title: &str,
        body: &str,
        label: &str,
    ) -> Result<CreatedIssue, GhIssuesError> {
        let tmp = std::env::temp_dir().join("wit-what-issue-body.txt");
        std::fs::write(&tmp, body).map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        let repo_flag = format!("{}/{}", self.owner, self.repo);
        let output = Command::new(&self.gh_path)
            .args([
                "issue", "create",
                "--repo", &repo_flag,
                "--title", title,
                "--label", label,
                "--body-file", &tmp.to_string_lossy(),
                "--json", "number,url",
            ])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        let _ = std::fs::remove_file(&tmp);
        // parse --json output for number and url, then fetch database_id separately
        // (see create_unit_as_subissue for the combined two-step flow)
        // ...
    }

    /// Create a ww-product-unit issue AND immediately link it as a subissue of the parent.
    /// This is a two-step operation: (1) create unit issue, (2) POST to sub_issues endpoint.
    pub fn create_unit_as_subissue(
        &self,
        parent_issue_number: i64,
        unit_title: &str,
        unit_body: &str,
    ) -> Result<CreatedIssue, GhIssuesError> {
        // Step 1: Create the unit issue
        let unit = self.create_issue(unit_title, unit_body, "ww-product-unit")?;

        // Step 2: Fetch the unit's numeric database ID (needed for sub_issue_id)
        let database_id = self.get_issue_database_id(unit.number)?;

        // Step 3: Link unit as subissue of parent via REST Subissues API
        self.add_subissue(parent_issue_number, database_id)?;

        Ok(CreatedIssue { number: unit.number, database_id, url: unit.url })
    }

    /// Get the numeric database id for an issue by number.
    /// This is distinct from `number` and from `node_id`.
    /// Required by the Subissues REST API as `sub_issue_id`.
    fn get_issue_database_id(&self, issue_number: i64) -> Result<i64, GhIssuesError> {
        let endpoint = format!(
            "repos/{}/{}/issues/{}",
            self.owner, self.repo, issue_number
        );
        let output = Command::new(&self.gh_path)
            .args(["api", &endpoint, "--jq", ".id"])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        if !output.status.success() {
            return Err(GhIssuesError::Transport(
                String::from_utf8_lossy(&output.stderr).to_string()
            ));
        }
        let id_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
        id_str.parse::<i64>().map_err(|e| GhIssuesError::Transport(e.to_string()))
    }

    /// Link an existing issue as a subissue of a parent issue.
    /// Uses: POST repos/{owner}/{repo}/issues/{parent_number}/sub_issues
    /// Body: {"sub_issue_id": <numeric_database_id>}
    fn add_subissue(
        &self,
        parent_issue_number: i64,
        sub_issue_database_id: i64,
    ) -> Result<(), GhIssuesError> {
        let endpoint = format!(
            "repos/{}/{}/issues/{}/sub_issues",
            self.owner, self.repo, parent_issue_number
        );
        let output = Command::new(&self.gh_path)
            .args([
                "api", &endpoint,
                "--method", "POST",
                "-F", &format!("sub_issue_id={}", sub_issue_database_id),
            ])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        if output.status.success() {
            Ok(())
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            if stderr.to_lowercase().contains("unauthorized") {
                return Err(GhIssuesError::Unauthorized);
            }
            Err(GhIssuesError::Transport(stderr.to_string()))
        }
    }

    /// List all subissues of a parent issue (for sync).
    /// Uses: GET repos/{owner}/{repo}/issues/{parent_number}/sub_issues
    pub fn list_subissues(&self, parent_issue_number: i64) -> Result<Vec<GhSubIssueRow>, GhIssuesError> {
        let endpoint = format!(
            "repos/{}/{}/issues/{}/sub_issues",
            self.owner, self.repo, parent_issue_number
        );
        let output = Command::new(&self.gh_path)
            .args(["api", &endpoint, "--paginate"])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        if !output.status.success() {
            return Err(GhIssuesError::Transport(
                String::from_utf8_lossy(&output.stderr).to_string()
            ));
        }
        // Response is an array of full issue objects; extract number, title, body, id
        let issues: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        // map to GhSubIssueRow...
        Ok(issues.into_iter().filter_map(|v| {
            Some(GhSubIssueRow {
                number: v.get("number")?.as_i64()?,
                database_id: v.get("id")?.as_i64()?,
                title: v.get("title")?.as_str()?.to_string(),
                body: v.get("body").and_then(|b| b.as_str()).unwrap_or("").to_string(),
            })
        }).collect())
    }

    /// Edit an existing issue's body (full replace via --body-file temp file).
    pub fn edit_issue_body(&self, number: i64, body: &str) -> Result<(), GhIssuesError> {
        let tmp = std::env::temp_dir().join("wit-what-issue-edit.txt");
        std::fs::write(&tmp, body).map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        let issue_ref = format!("{}/{}/issues/{}", self.owner, self.repo, number);
        let output = Command::new(&self.gh_path)
            .args(["issue", "edit", &issue_ref, "--body-file", &tmp.to_string_lossy()])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        let _ = std::fs::remove_file(&tmp);
        if output.status.success() { Ok(()) }
        else { Err(GhIssuesError::Transport(String::from_utf8_lossy(&output.stderr).to_string())) }
    }

    /// List all open issues with the given label.
    pub fn list_issues_by_label(&self, label: &str) -> Result<Vec<GhIssueRow>, GhIssuesError> {
        let repo_flag = format!("{}/{}", self.owner, self.repo);
        let output = Command::new(&self.gh_path)
            .args([
                "issue", "list",
                "--repo", &repo_flag,
                "--label", label,
                "--state", "open",
                "--limit", "1000",
                "--json", "number,title,body,labels,url",
            ])
            .output()
            .map_err(|e| GhIssuesError::Transport(e.to_string()))?;
        if !output.status.success() {
            return Err(GhIssuesError::Transport(
                String::from_utf8_lossy(&output.stderr).to_string()
            ));
        }
        serde_json::from_slice(&output.stdout)
            .map_err(|e| GhIssuesError::Transport(e.to_string()))
    }
}
```

### Pattern 2: GitHub Subissues API — Three-ID System

**Critical understanding:** GitHub issues have THREE distinct identifiers:

| Field | Type | Example | Used for |
|-------|------|---------|---------|
| `number` | integer | `42` | Human-readable issue number; used in URLs and `gh issue` commands |
| `id` | integer | `502407912` | Numeric database ID; this is what `sub_issue_id` requires in the REST Subissues API |
| `node_id` | string | `"MDExOlB..."` | GraphQL global node ID; returned by `gh issue view --json id` |

**Gotcha:** `gh issue view --json id` returns the **node_id** (GraphQL string), not the numeric `id`. To get the numeric database ID, use `gh api repos/{owner}/{repo}/issues/{number} --jq .id`.

**Subissues REST API endpoints (as of 2026-03-10 API version):**
```
POST   repos/{owner}/{repo}/issues/{parent_number}/sub_issues
       Body: {"sub_issue_id": <numeric_database_id>}
       → creates the parent↔child relationship

GET    repos/{owner}/{repo}/issues/{parent_number}/sub_issues
       Query: per_page=100&page=1
       → returns array of child issue objects (with number, id, title, body)

GET    repos/{owner}/{repo}/issues/{issue_number}/parent
       → returns the parent issue object (for verification)

DELETE repos/{owner}/{repo}/issues/{parent_number}/sub_issue
       Body: {"sub_issue_id": <numeric_database_id>}
       → removes the relationship
```

**Using `gh api` for subissues:**
```bash
# Create subissue relationship (unit's database_id = 123456789, parent's number = 37)
gh api repos/BigscreenVR/beyond-outgoing/issues/37/sub_issues \
  --method POST \
  -F sub_issue_id=123456789

# List subissues of parent issue 37
gh api repos/BigscreenVR/beyond-outgoing/issues/37/sub_issues --paginate

# Get parent of unit issue 42
gh api repos/BigscreenVR/beyond-outgoing/issues/42/parent
```

**No special headers required:** The Subissues REST API is generally available as of late 2024/2025 and does NOT require the `GraphQL-Features: sub_issues` header (that header is only for the GraphQL mutation path, which this phase does not use).

### Pattern 3: GH Issue Body Format (JSON code block) — Simplified (No Cross-Links)

**What:** Issue body is a fenced JSON code block. The `schema_version` field enables future migrations. No `unit_refs[]` or `parent_ref` needed — the subissue relationship is tracked by GitHub natively.

**Example body for `ww-product`:**
````
```json
{
  "schema_version": 1,
  "product_id": "550e8400-e29b-41d4-a716-446655440000",
  "image_url": "https://raw.githubusercontent.com/BigscreenVR/beyond-outgoing/product-images/images/550e8400.jpg",
  "shopify_product_url": "https://bigscreenvr.myshopify.com/admin/products/12345"
}
```
````

**Example body for `ww-product-unit`:**
````
```json
{
  "schema_version": 1,
  "serial_id": "HMD-001",
  "product_id": "550e8400-e29b-41d4-a716-446655440000",
  "state": "Created",
  "assigned_card_id": null
}
```
````

**Parsing pattern:**
```rust
fn parse_issue_body_json(body: &str) -> Result<serde_json::Value, String> {
    let content = body
        .trim()
        .trim_start_matches("```json")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str(content).map_err(|e| format!("JSON parse error: {}", e))
}
```

### Pattern 4: V004 Migration — Reconcile Products Table (Subissues Edition)

**What:** V001 has `products` and `serial_instances` tables but with divergent schema. V004 must bring them in line with the current spec. With the subissues approach, `unit_refs_json` and `parent_ref` are NOT needed in SQLite — the GitHub relationship is authoritative. The local SQLite `product_units` table discovers its parents via the `product_id` field in each unit row, which comes from the unit issue body JSON.

**Key divergences to fix:**

| V001 Column | Issue | V004 Fix |
|-------------|-------|---------|
| `github_issue_id TEXT` (products) | Name should be `github_issue_number INTEGER` | Add `github_issue_number INTEGER`; deprecate `github_issue_id` |
| `serial_instances` missing `github_issue_number` | Need to store unit issue number for sync | Add `github_issue_number INTEGER` to serial_instances |
| Old `unit_refs_json TEXT` idea | NOT needed — subissues replace this | Do NOT add this column |
| Old `parent_ref TEXT` on serial_instances | NOT needed — subissues replace this | Do NOT add this column |

**V004 SQL:**
```sql
-- V004: Reconcile products and serial_instances with DATA-FLOW.md Layer 3/4 spec.
-- Adds github_issue_number (integer) alongside legacy github_issue_id (text).
-- NOTE: unit_refs_json and parent_ref are NOT added — subissues handle parent/child linking.
ALTER TABLE products ADD COLUMN github_issue_number INTEGER;
ALTER TABLE serial_instances ADD COLUMN github_issue_number INTEGER;
```

### Pattern 5: ProductRow and ProductUnitRow in SqliteStore (Subissues Edition)

**What:** New row structs and CRUD methods matching the existing `RecipientRow`/`CardRow` pattern. Simpler than the cross-link approach — no `unit_refs_json` or `parent_ref` fields.

```rust
// Source: adapted from crates/service/src/db/sqlite.rs RecipientRow pattern
#[derive(Debug, Clone)]
pub struct ProductRow {
    pub product_id: String,
    pub name: String,
    pub image_url: Option<String>,
    pub shopify_product_url: Option<String>,
    pub github_issue_number: Option<i64>,   // Added in V004 migration
}

#[derive(Debug, Clone)]
pub struct ProductUnitRow {
    pub serial_id: String,
    pub product_id: String,               // FK to ProductRow; used to discover parent
    pub state: String,                    // "Created" | "Assigned" | "In Transit" | ...
    pub assigned_card_id: Option<String>,
    pub assigned_at: Option<String>,
    pub github_issue_number: Option<i64>, // Added in V004
}
```

**Key methods needed:**
- `upsert_product(row: &ProductRow)` — insert or update by `product_id`
- `read_all_products()` — returns `Vec<ProductRow>` for catalog display
- `read_product_by_id(id: &str)` — single product lookup
- `upsert_product_unit(row: &ProductUnitRow)`
- `read_units_by_product(product_id: &str)` — for nested display

**No `update_product_unit_refs` method needed** — the subissues relationship lives in GitHub, not SQLite.

### Pattern 6: Sync Flow (Subissues Edition)

**Old approach (cross-links):** Parse `unit_refs[]` from parent body, fetch each by number.

**New approach (subissues):**
```
For each ww-product issue:
  1. Parse issue body → ProductRow (no unit_refs parsing)
  2. Upsert to SQLite products table
  3. Call list_subissues(product_issue_number) → array of unit issue objects
  4. For each subissue:
     a. Verify label is ww-product-unit
     b. Parse unit body → ProductUnitRow
     c. Upsert to SQLite serial_instances table
```

This is slightly more API calls (one `list_subissues` per product) but eliminates the fragility of maintaining cross-link arrays in issue bodies. For a small catalog (10-50 products), this is acceptable.

### Pattern 7: Product Image Upload (ProductImagesBranchClient)

**What:** Identical to `AvatarBranchClient` but targets the `product-images` orphan branch and uses `images/{product_id}.jpg` (or `.png`) as the path. Flat structure (no per-product subfolder) is the simplest default.

**Key differences from AvatarBranchClient:**
- Branch: `product-images` (not `recipient-avatars`)
- Path: `images/{product_id}.{ext}` (flat)
- Commit message: `chore(product-images): update {product_id}`
- Content-Type implication: may be JPEG or PNG (determine from file picker output)

**Reuse exactly:**
- `ensure_branch_exists()` — copy verbatim, change branch name constant
- `get_file_sha()` — copy verbatim, change path template
- `upload_avatar()` → `upload_image()` — copy verbatim, change path + commit message
- Empty tree SHA constant: `4b825dc642cb6eb9a060e54bf8d69288fbee4904` (unchanged)

### Pattern 8: Shopify Product Image Auto-Fetch

**What:** When a product has a `shopify_product_url` but no `image_url`, fetch the first product image from Shopify Admin API and store it on the product-images branch.

**Shopify API endpoint:**
```
GET /admin/api/2024-01/products/{id}.json
→ .product.images[0].src
```

The product ID is the last path segment of the Shopify admin URL (same extraction pattern as `shopify_customer_id` from Shopify Profile URL).

**Implementation note:** Extract product ID from `shopify_product_url` (last path segment after `/products/`), call Shopify API, download the image, upload to product-images branch, update `image_url` in SQLite and GH Issue body.

### Pattern 9: Extended ByProductShipped Option Grid

**What:** Currently `extract_product_tiles` derives product names from `card.product_names` (freeform strings from GH Project). After Phase 17, the grid should be backed by `ProductRow` from SQLite.

**Current signature:**
```rust
pub fn extract_product_tiles(cards: &[DashboardCardViewModel]) -> Vec<(String, Option<String>)>
```

**Target signature:**
```rust
pub fn build_product_option_grid(
    products: &[ProductRow],
    cards: &[DashboardCardViewModel],
) -> Vec<ProductTile>

pub struct ProductTile {
    pub product_id: String,
    pub name: String,
    pub image_url: Option<String>,
    pub recipient_count: usize,   // cards referencing this product
}
```

The recipient count is computed by counting cards whose `product_refs` contain this `product_id`.

### Anti-Patterns to Avoid

- **Using `--body` flag for issue body:** The GH CLI arg-length limit is hit when body content is large. Always use `--body-file` with a temp file.
- **Using `gh issue view --json id` to get the sub_issue_id:** This returns the GraphQL `node_id` (a string like `"MDExOlB..."`), NOT the numeric database `id`. The Subissues REST API requires the numeric integer `id`. Use `gh api repos/{owner}/{repo}/issues/{number} --jq .id` to get it.
- **Storing cross-link arrays in SQLite:** `unit_refs_json` and `parent_ref` are NOT needed since subissues replace this. Do not add them to V004.
- **Reading products from in-memory Repository:** RULE-03 is mandatory. All product reads must come from SQLite.
- **Adding `github_profile_url` anywhere:** RULE-01. Never.
- **Using `item_summary` for new product code:** RULE-07. All new code uses `product_refs: Vec<ProductRef>`.
- **Running `gh issue create` with `--body` for JSON bodies:** JSON bodies contain special characters that break shell arg parsing on Windows. Always write to a temp file and use `--body-file`.
- **Using the GraphQL `addSubIssue` mutation:** The REST Subissues API is simpler (`gh api` with `--method POST`), does not require feature preview headers, and is generally available. Use REST.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| GH API auth | Custom token storage/injection | `gh` CLI auth (existing) | `gh` manages its own auth; consistent with GhCliProjectClient |
| SQLite migrations | Manual `ALTER TABLE` in code | `refinery` embedded migrations | Already used; handles ordering and idempotency |
| UUID generation | Custom ID scheme | `uuid` crate v4 | UUIDs are stable, globally unique, and sortable enough for product IDs |
| Orphan branch management | Custom git operations | Copy `AvatarBranchClient` pattern | Handles 404/422 idempotency, empty tree SHA, temp-file upload already |
| Issue body parsing | Custom regex/string splits | `serde_json` deserialization | JSON is the agreed format; serde handles version-aware parsing |
| Parent↔child relationship | `unit_refs[]` / `parent_ref` cross-links in body JSON | GitHub Subissues API | Native GitHub feature; no stale-ref risk; visible in GH UI; API is stable |
| Product image resize | Custom pixel manipulation | `image` crate (or defer) | Complex edge cases (EXIF, profiles, format detection); within Claude's discretion |

**Key insight:** Every external I/O pattern in this phase (GitHub Issues, orphan branch upload, SQLite CRUD) already has a working implementation in this codebase. Subissues add one new `gh api` call shape — it follows the same pattern as existing `gh api` calls in `GhCliProjectClient`.

---

## Common Pitfalls

### Pitfall 1: gh CLI `--body` arg-length failure on Windows
**What goes wrong:** `gh issue create --body '{"long":"json"}'` fails silently or is truncated on Windows when body exceeds ~8KB.
**Why it happens:** Windows has a shorter command-line arg limit than Unix. JSON bodies routinely hit this.
**How to avoid:** Always use `--body-file <temp_path>`. Write to `std::env::temp_dir().join("wit-what-issue-body.txt")`, call gh, then `std::fs::remove_file()`.
**Warning signs:** Issues created with truncated body, serde parse errors on read-back.

### Pitfall 2: Confusing the three GitHub issue identifiers
**What goes wrong:** Using `node_id` (from `gh issue view --json id`) as the `sub_issue_id` in the REST Subissues API. The API returns a 422 Validation failed error.
**Why it happens:** GitHub has three distinct identifiers for issues: `number` (human-readable), `id` (numeric database ID), and `node_id` (GraphQL string). `gh issue view --json id` returns `node_id` (string), not the numeric `id`. The Subissues REST API `sub_issue_id` requires the numeric integer `id`.
**How to avoid:** Always fetch the numeric `id` via `gh api repos/{owner}/{repo}/issues/{number} --jq .id`. Store this as `database_id` in the `CreatedIssue` return struct so it's available for the subissue link call without a second round-trip.
**Warning signs:** `gh api ... sub_issues` returns 422; unit issue exists but is not linked to parent.

### Pitfall 3: Two-step unit creation is not atomic
**What goes wrong:** Step 1 (create unit issue) succeeds but Step 2 (link as subissue) fails. The unit issue exists in GitHub but is not linked to the parent. SQLite may have been written already.
**Why it happens:** Two separate API calls with no transaction boundary.
**How to avoid:** On failure of Step 2, log the error with the unit issue number so the operator can manually add the subissue relationship. Do not write to SQLite until both steps succeed. On sync, verify the subissue relationship exists (if a `ww-product-unit` issue appears in the unit label list but is NOT a subissue of any `ww-product`, treat it as an orphan and log a warning).
**Warning signs:** Unit exists in GH Issues with `ww-product-unit` label but does not appear under the parent product in the GH UI.

### Pitfall 4: V001 `products` table schema divergence
**What goes wrong:** V001 has `github_issue_id TEXT` but the spec requires `github_issue_number INTEGER`. Writing integers to a TEXT column works in SQLite (dynamic typing) but causes confusion and breaks typed queries.
**Why it happens:** V001 was written before the issue number vs ID distinction was clarified.
**How to avoid:** Add V004 migration that adds `github_issue_number INTEGER` alongside the existing `github_issue_id TEXT` column. Write to `github_issue_number`; treat `github_issue_id` as deprecated.
**Warning signs:** `read_all_products()` returns 0 records when products exist in SQLite, or `github_issue_number` always 0.

### Pitfall 5: Mutex lock nesting in SqliteStore
**What goes wrong:** A method that calls `conn.lock()` and then internally calls another SqliteStore method that also calls `conn.lock()` deadlocks.
**Why it happens:** `Mutex<Connection>` is not re-entrant.
**How to avoid:** Every SqliteStore method acquires the lock exactly once at the top, completes all work, and releases. Never call a store method from within another store method.
**Warning signs:** App hangs on first product sync with no error output.

### Pitfall 6: Subissue list pagination
**What goes wrong:** A product with more than 30 units (the default per_page) only shows partial units after sync.
**Why it happens:** The REST Subissues API defaults to `per_page=30`. The `gh api --paginate` flag handles this automatically.
**How to avoid:** Always use `--paginate` with `gh api` when listing subissues. For the current catalog size (likely <30 units per product), this is a no-op performance-wise.
**Warning signs:** Product shows 30 units in SQLite but more exist in GitHub.

### Pitfall 7: Fencing characters in JSON code block body
**What goes wrong:** If the product name or other fields contain backtick characters, the fenced code block in the GH Issue body is broken.
**Why it happens:** Markdown code fences use triple backticks.
**How to avoid:** Validate/escape input defensively. Product names are unlikely to contain backticks.
**Warning signs:** GH Issue body renders as broken Markdown; serde parse fails with "trailing characters after JSON".

### Pitfall 8: `extract_product_tiles` product/card name mismatch during transition
**What goes wrong:** After Phase 17, the product catalog comes from SQLite `ProductRow`, but cards still have `product_names: Vec<String>` (freeform strings from GH Project). The "filter by product" logic must use `product_id` matching after PROD-03, not name-string matching.
**Why it happens:** Two parallel representations of "what product is on a card" exist during the transition.
**How to avoid:** Keep the existing name-string fallback in `filter_cards_by_product` as long as `product_refs` is empty for a card. Once PROD-03 wires `product_refs`, prefer `product_id` matching.
**Warning signs:** Clicking a product tile in ByProductShipped shows 0 cards even when cards reference that product.

---

## Code Examples

### Create a ww-product GH Issue

```rust
// Source: adapted from crates/integrations/src/github/avatar_branch_client.rs upload_avatar()
fn create_product_issue(
    gh_path: &Path,
    name: &str,
    product_id: &str,
    shopify_url: Option<&str>,
) -> Result<(i64, i64), String> {  // (issue_number, database_id)
    let body_json = serde_json::json!({
        "schema_version": 1,
        "product_id": product_id,
        "image_url": null,
        "shopify_product_url": shopify_url
        // NOTE: no unit_refs — subissues replace this
    });
    let body = format!("```json\n{}\n```", serde_json::to_string_pretty(&body_json).unwrap());

    let tmp = std::env::temp_dir().join("wit-what-issue-body.txt");
    std::fs::write(&tmp, &body).map_err(|e| e.to_string())?;

    let output = Command::new(gh_path)
        .args([
            "issue", "create",
            "--repo", "BigscreenVR/beyond-outgoing",
            "--title", name,
            "--label", "ww-product",
            "--body-file", &tmp.to_string_lossy(),
            "--json", "number,url",
        ])
        .output()
        .map_err(|e| e.to_string())?;
    let _ = std::fs::remove_file(&tmp);

    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).to_string());
    }

    let json: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|e| e.to_string())?;
    let number = json["number"].as_i64().ok_or("missing number")?;

    // Fetch numeric database id for potential future use as sub_issue_id
    let db_id = get_issue_database_id(gh_path, number)?;
    Ok((number, db_id))
}
```

### Create a ww-product-unit as subissue (two-step)

```rust
// Source: GitHub REST Subissues API — https://docs.github.com/en/rest/issues/sub-issues
fn create_unit_as_subissue(
    gh_path: &Path,
    parent_issue_number: i64,
    serial_id: &str,
    product_id: &str,
) -> Result<i64, String> {  // returns unit issue number
    // Step 1: Create the unit issue
    let body_json = serde_json::json!({
        "schema_version": 1,
        "serial_id": serial_id,
        "product_id": product_id,
        "state": "Created",
        "assigned_card_id": null
        // NOTE: no parent_ref — subissues replace this
    });
    let body = format!("```json\n{}\n```", serde_json::to_string_pretty(&body_json).unwrap());
    let tmp = std::env::temp_dir().join("wit-what-unit-body.txt");
    std::fs::write(&tmp, &body).map_err(|e| e.to_string())?;

    let output = Command::new(gh_path)
        .args([
            "issue", "create",
            "--repo", "BigscreenVR/beyond-outgoing",
            "--title", serial_id,
            "--label", "ww-product-unit",
            "--body-file", &tmp.to_string_lossy(),
            "--json", "number",
        ])
        .output()
        .map_err(|e| e.to_string())?;
    let _ = std::fs::remove_file(&tmp);
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).to_string());
    }
    let unit_number: serde_json::Value = serde_json::from_slice(&output.stdout)
        .map_err(|e| e.to_string())?;
    let unit_number = unit_number["number"].as_i64().ok_or("missing number")?;

    // Step 2: Get unit's numeric database id (distinct from number and node_id)
    let unit_db_id = get_issue_database_id(gh_path, unit_number)?;

    // Step 3: Link unit as subissue of parent
    let endpoint = format!(
        "repos/BigscreenVR/beyond-outgoing/issues/{}/sub_issues",
        parent_issue_number
    );
    let link_output = Command::new(gh_path)
        .args([
            "api", &endpoint,
            "--method", "POST",
            "-F", &format!("sub_issue_id={}", unit_db_id),
        ])
        .output()
        .map_err(|e| e.to_string())?;
    if !link_output.status.success() {
        // Log error — unit issue was created but not linked. Don't insert to SQLite yet.
        return Err(format!(
            "Unit {} created (issue #{}) but subissue link failed: {}",
            serial_id, unit_number,
            String::from_utf8_lossy(&link_output.stderr)
        ));
    }

    Ok(unit_number)
}

fn get_issue_database_id(gh_path: &Path, issue_number: i64) -> Result<i64, String> {
    let endpoint = format!("repos/BigscreenVR/beyond-outgoing/issues/{}", issue_number);
    let output = Command::new(gh_path)
        .args(["api", &endpoint, "--jq", ".id"])
        .output()
        .map_err(|e| e.to_string())?;
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).to_string());
    }
    String::from_utf8_lossy(&output.stdout)
        .trim()
        .parse::<i64>()
        .map_err(|e| e.to_string())
}
```

### List subissues for sync

```rust
// Source: GitHub REST Subissues API — https://docs.github.com/en/rest/issues/sub-issues
fn list_subissues(gh_path: &Path, parent_number: i64) -> Result<Vec<serde_json::Value>, String> {
    let endpoint = format!(
        "repos/BigscreenVR/beyond-outgoing/issues/{}/sub_issues",
        parent_number
    );
    let output = Command::new(gh_path)
        .args(["api", &endpoint, "--paginate"])
        .output()
        .map_err(|e| e.to_string())?;
    if !output.status.success() {
        return Err(String::from_utf8_lossy(&output.stderr).to_string());
    }
    serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())
}
```

### Parse ww-product issue body (no unit_refs)

```rust
// Source: codebase pattern — serde_json deserialization
#[derive(serde::Deserialize)]
struct ProductIssueBody {
    schema_version: u32,
    product_id: String,
    image_url: Option<String>,
    shopify_product_url: Option<String>,
    // NOTE: no unit_refs field — subissues replace this
}

#[derive(serde::Deserialize)]
struct ProductUnitIssueBody {
    schema_version: u32,
    serial_id: String,
    product_id: String,
    state: String,
    assigned_card_id: Option<String>,
    // NOTE: no parent_ref field — subissues replace this
}

fn parse_product_body(raw_body: &str) -> Result<ProductIssueBody, String> {
    let inner = raw_body
        .trim()
        .trim_start_matches("```json")
        .trim_end_matches("```")
        .trim();
    serde_json::from_str(inner).map_err(|e| format!("body parse error: {}", e))
}
```

### `gh issue edit` body update pattern

```rust
// Source: adapted from avatar_branch_client.rs upload_avatar() temp-file pattern
fn update_product_issue_body(
    gh_path: &Path,
    issue_number: i64,
    new_body: &str,
) -> Result<(), String> {
    let tmp = std::env::temp_dir().join("wit-what-issue-edit.txt");
    std::fs::write(&tmp, new_body).map_err(|e| e.to_string())?;

    let issue_ref = format!("BigscreenVR/beyond-outgoing#{}", issue_number);
    let output = Command::new(gh_path)
        .args([
            "issue", "edit", &issue_ref,
            "--repo", "BigscreenVR/beyond-outgoing",
            "--body-file", &tmp.to_string_lossy(),
        ])
        .output()
        .map_err(|e| e.to_string())?;
    let _ = std::fs::remove_file(&tmp);

    if output.status.success() { Ok(()) }
    else { Err(String::from_utf8_lossy(&output.stderr).to_string()) }
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `item_summary` pipe-separated string on cards | `product_refs: Vec<ProductRef>` | Phase 15 (RULE-07) | Cards now reference products by ID; freeform strings deprecated |
| Products derived from GH Project parallel-array columns | Products as first-class entities in SQLite backed by GH Issues (`ww-product`) | Phase 17 (this phase) | Standalone product catalog; images; structured unit tracking |
| `extract_product_tiles` derives names from card's `product_names` | `build_product_option_grid` reads from `ProductRow` SQLite catalog | Phase 17 | Product tiles show custom images, recipient counts, metadata |
| No product images | Product images on `product-images` orphan branch; auto-fetched from Shopify | Phase 17 | Product tiles have images |
| `unit_refs[]` in parent body + `parent_ref` in unit body (cross-links) | GitHub native Subissues API (POST sub_issues endpoint) | Phase 17 decision | No stale cross-link arrays; native GH UI grouping; simpler body JSON |

**Deprecated/outdated (revised for subissues):**
- `products.github_issue_id TEXT`: V001 column, replaced by `products.github_issue_number INTEGER` added in V004.
- `products.is_serializable INTEGER`: V001 column, design locks all products as serializable. Column kept (always written as `1`).
- `unit_refs[]` in parent issue body: Replaced by GitHub native subissues. Not added to V004.
- `parent_ref` in unit issue body: Replaced by GitHub native subissues. Not added to V004.
- `extract_product_tiles(cards)` using only card-derived product names: replaced by catalog-backed grid.

---

## Open Questions

1. **`product_id` generation scheme**
   - What we know: DATA-FLOW.md says "UUID or slug"
   - What's unclear: UUID v4 (random) vs slug (from product name, URL-safe).
   - Recommendation: Use UUID v4 via the `uuid` crate. Readability in GH Issue body is served by the issue title (product name).

2. **Shopify product image auto-fetch timing**
   - What we know: "Products without a custom image auto-fetch the first Shopify product image when linked" (CONTEXT.md)
   - What's unclear: When exactly — at product creation, at sync time, or on first product tile render?
   - Recommendation: At product creation/link time (when `shopify_product_url` is set or changed). Retry on next sync if failed.

3. **`--limit 1000` for `gh issue list`**
   - What we know: The current product catalog is small (dozens of products).
   - Recommendation: `--limit 1000` is sufficient. If catalog ever exceeds 1000 entries, add pagination then.

4. **Migration strategy for existing `product_names` on cards**
   - What we know: Cards currently have freeform `product_names` from GH Project. PROD-03 requires `product_refs` instead.
   - Recommendation: Keep `product_names` as a display fallback. New product assignments use `product_refs`. Do not attempt automatic name→product_id resolution (too fragile).

5. **Subissue database_id fetch — extra round-trip cost**
   - What we know: Creating a unit requires 3 API calls: create issue, get database_id, POST sub_issues.
   - What's unclear: Can we skip the database_id fetch? The `gh issue create --json` output includes `number` and `url` but not `id` (numeric). The `--json` field list for `gh issue create` does not include `id`.
   - Recommendation: Accept the extra round-trip. It is one additional `gh api` call per unit creation, which is a rare event. For sync (listing existing units), the sub_issues GET endpoint returns full issue objects including the numeric `id`, so no extra call is needed in that path.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in (`#[test]`, `#[cfg(test)]`) |
| Config file | none (standard `cargo test`) |
| Quick run command | `cargo test -p integrations issues 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| CLOUD-03 | `create_issue` returns issue number; body parses back correctly | unit | `cargo test -p integrations parse_product_body 2>&1` | ❌ Wave 0 |
| CLOUD-04 | `edit_issue_body` writes temp file; returns Ok on success | unit | `cargo test -p integrations update_product_issue 2>&1` | ❌ Wave 0 |
| PROD-01 | `build_product_option_grid` returns tiles sorted by name with correct recipient counts | unit | `cargo test -p app build_product_option_grid 2>&1` | ❌ Wave 0 |
| PROD-02 | `ProductRow` round-trips through `upsert_product` / `read_all_products` in-memory SQLite | unit | `cargo test -p service product_row_roundtrip 2>&1` | ❌ Wave 0 |
| PROD-03 | `product_refs` stored in cards table; read back with correct `product_id` | unit | `cargo test -p service card_product_refs_roundtrip 2>&1` | ❌ Wave 0 |
| PROD-04 | Product add UI: `ProductUnitRow` with `state="Created"` inserted; shown nested under parent | unit | `cargo test -p service product_unit_upsert 2>&1` | ❌ Wave 0 |
| PROD-05 | `create_unit_as_subissue` body parsing correct; parse_unit_body round-trips | unit (parsing only) | `cargo test -p integrations parse_unit_body 2>&1` | ❌ Wave 0 |

**Note on PROD-05 testing:** The two-step subissue creation (create issue + POST sub_issues) requires live GitHub API access and cannot be unit-tested in isolation without mocking `Command`. Test the body serialization/parsing helpers (which are pure functions) in unit tests. The integration (actual subissue linking) is verified in UAT.

### Sampling Rate
- **Per task commit:** `cargo test -p integrations -p service -p app 2>&1`
- **Per wave merge:** `cargo test --workspace 2>&1`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `crates/integrations/src/github/issues_client.rs` — `GhIssuesClient` with unit tests for `parse_product_body`, `parse_unit_body`, `create_issue` parsing helpers, `list_subissues` JSON parsing
- [ ] `crates/service/src/db/migrations/V004__products_schema_fix.sql` — reconcile schema (no unit_refs_json, no parent_ref)
- [ ] `crates/service/src/db/sqlite.rs` — `ProductRow`, `ProductUnitRow`, CRUD methods with `#[cfg(test)]` in-memory tests
- [ ] `crates/app/src/dashboard/discovery.rs` — `build_product_option_grid` unit tests with mock `ProductRow` data

*(All test infrastructure is standard `cargo test` — no new test framework installation needed.)*

---

## Sources

### Primary (HIGH confidence)
- `gh issue create --help` (local CLI) — confirmed no `--parent` flag exists; `--body-file` confirmed
- `gh issue view --help` (local CLI) — confirmed `id` JSON field returns `node_id` (string), not numeric database id
- `gh api repos/cli/cli/issues/1` (live API call) — confirmed three-ID system: `number` (integer), `id` (integer database ID), `node_id` (string)
- https://docs.github.com/en/rest/issues/sub-issues — official REST API endpoints for sub-issues (fetched 2026-03-25): POST/GET/DELETE/PATCH endpoints, `sub_issue_id` is numeric integer database id
- Direct codebase read: `crates/integrations/src/github/gh_cli_client.rs` — GhCliProjectClient pattern, auth error detection, subprocess pattern
- Direct codebase read: `crates/integrations/src/github/avatar_branch_client.rs` — temp-file upload pattern, orphan branch creation, idempotency
- Direct codebase read: `crates/service/src/db/sqlite.rs` — SqliteStore WAL pattern, RecipientRow/CardRow upsert pattern
- Direct codebase read: `crates/service/src/db/migrations/V001__initial_schema.sql` — existing `products` and `serial_instances` table schema
- `.planning/phases/17-gh-issues-client-and-product-catalog/17-CONTEXT.md` — all locked decisions (updated with subissues)

### Secondary (MEDIUM confidence)
- https://jessehouwing.net/create-github-issue-hierarchy-using-the-api/ — practical guide confirming `sub_issue_id` = numeric `id` (not number, not node_id); `gh api` usage pattern (verified against official docs)
- https://github.com/joshjohanning/github-misc-scripts/blob/main/gh-cli/add-sub-issue-to-issue.sh — GraphQL addSubIssue mutation example (confirms GraphQL path requires `GraphQL-Features` headers; REST path is simpler)
- V001 schema cross-referenced against CONTEXT.md product entity fields — identified `github_issue_id` TEXT vs required `github_issue_number` INTEGER divergence

### Tertiary (LOW confidence)
- None

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — verified against Cargo.toml files and existing code
- Architecture: HIGH — all patterns are direct adaptations of existing codebase code
- Subissues API: HIGH — verified against official GitHub docs (fetched live) and confirmed via local `gh api` call showing three-ID system
- Pitfalls: HIGH — grounded in V001 schema, existing code patterns, CLI verification, and live API testing
- Migration needs: HIGH — V001 SQL confirmed directly, divergences identified precisely; subissues simplify V004

**Research date:** 2026-03-25
**Valid until:** 2026-04-25 (stable domain — gh CLI flags and rusqlite patterns are stable; Subissues REST API is generally available)
