---
phase: 17-gh-issues-client-and-product-catalog
verified: 2026-03-26T07:06:49Z
status: passed
score: 9/9 must-haves verified
re_verification: false
---

# Phase 17: GH Issues Client and Product Catalog — Verification Report

**Phase Goal:** Users can browse a standalone product list, products have structured entities in SQLite and GH Issues, and cards reference products via structured refs instead of freeform text.
**Verified:** 2026-03-26T07:06:49Z
**Status:** PASSED
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | GhIssuesClient can create/edit/list ww-product GH Issues with structured bodies | VERIFIED | `issues_client.rs` has `create_issue`, `edit_issue_body`, `list_issues_by_label` with full subprocess wiring |
| 2 | Products have structured entities in SQLite (ProductRow, ProductUnitRow) with V004/V005 migrations | VERIFIED | `V004__products_schema_fix.sql` adds `github_issue_number` to both tables; `sqlite.rs` has `ProductRow`, `ProductUnitRow` with full CRUD |
| 3 | Product sync populates SQLite from GH Issues on app load and periodically | VERIFIED | `live_client.rs:787` `sync_products` calls `list_issues_by_label("ww-product")` + `list_subissues` + `upsert_product`/`upsert_product_unit`; stale cleanup and Shopify image auto-fetch present |
| 4 | User can browse a standalone product list backed by SQLite catalog | VERIFIED | `discovery.rs:323` `build_product_option_grid` takes `&[ProductRow]`; tab switch calls `refresh_product_grid` which reads `store.read_all_products()` |
| 5 | Product detail sidecar opens with Set Image, View on Shopify, GH Issue link, serial units list | VERIFIED | `product-detail.slint` has all elements; `main.rs` wires `on_product_sidecar_view_shopify`, `on_product_sidecar_view_issue`, `upload_image` via file picker |
| 6 | Add Product form opens, creates ww-product GH Issue and inserts to SQLite on submit | VERIFIED | `main.rs:3341` `on_on_add_product_submit` calls `client.create_issue(…, "ww-product")` then `store.upsert_product` |
| 7 | Serial unit creation: inline SN input, Enter confirms, creates ww-product-unit subissue | VERIFIED | `product-detail.slint:263` inline `TextInput` with `create-unit-with-serial` callback; `main.rs:3563` wires to `create_unit_as_subissue` |
| 8 | Cards reference products via structured `product_refs` column (V005) in SQLite | VERIFIED | `V005__card_product_refs.sql` adds `product_refs_json TEXT DEFAULT '[]'`; `live_client.rs:427` deserializes to `Vec<ProductRef>`; roundtrip test passes |
| 9 | Shipment Product Lookup modal shows catalog products from SQLite | VERIFIED | `main.rs:3047` pre-populates lookup modal with `store.read_all_products()`; search also queries catalog |

**Score:** 9/9 truths verified

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `crates/integrations/src/github/issues_client.rs` | GhIssuesClient with CRUD + subissues | VERIFIED | 384 lines; all 7 public methods present; 5 unit tests passing |
| `crates/integrations/src/github/product_images_branch_client.rs` | ProductImagesBranchClient with upload_image | VERIFIED | File exists; `pub fn upload_image` at line 169 |
| `crates/service/src/db/migrations/V004__products_schema_fix.sql` | Adds github_issue_number to products + serial_instances | VERIFIED | Exact ALTER TABLE statements confirmed; no unit_refs_json or parent_ref |
| `crates/service/src/db/migrations/V005__card_product_refs.sql` | Adds product_refs_json to cards | VERIFIED | `ALTER TABLE cards ADD COLUMN product_refs_json TEXT DEFAULT '[]'` |
| `crates/service/src/db/sqlite.rs` | ProductRow, ProductUnitRow, CRUD methods, delete_product | VERIFIED | All 6 public methods present; 4 product-specific unit tests passing (`cargo test -p service --lib`) |
| `crates/app/src/live_client.rs` | Product sync in run_sync_cycle; stale cleanup; Shopify image auto-fetch | VERIFIED | `sync_products` at line 787; stale cleanup at line 862; Shopify image fetch at line 866 |
| `crates/app/src/dashboard/discovery.rs` | build_product_option_grid backed by ProductRow slice | VERIFIED | `fn build_product_option_grid(products: &[ProductRow], …)` at line 323 with tests |
| `crates/app/ui/option-grid.slint` | ProductGrid without ellipsis menu | VERIFIED | No `ellipsis-set-image`, `ellipsis-detail`, `ellipsis-view-shopify`, or `ellipsis-archive` callbacks |
| `crates/app/ui/product-detail.slint` | Redesigned sidecar with inline SN input, unit list, links | VERIFIED | `create-unit-with-serial` callback; `TextInput` inline SN input at line 263; clickable links present |
| `crates/app/ui/add-product-form.slint` | AddProductForm matching LookupModal styling | VERIFIED | `Colors.surface`, `border-radius: 12px`, `width: 480px`, `font-weight: 600`, close button "X" |
| `crates/app/ui/product-picker.slint` | ProductAddToCardPicker component | VERIFIED | `export component ProductAddToCardPicker` at line 16 |
| `crates/app/src/main.rs` | All product callbacks wired: create, unit, image, sidecar, grid, lookup | VERIFIED | `on_on_add_product_submit`, `on_on_product_create_unit_with_serial`, `on_on_product_sidecar_view_shopify`, `on_on_product_sidecar_view_issue`, `upload_image`, `build_product_option_grid` all present |
| `crates/integrations/src/github/mod.rs` | issues_client and product_images_branch_client exported | VERIFIED | `pub mod issues_client` at line 4; `pub mod product_images_branch_client` at line 6 |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `issues_client.rs` | gh CLI binary | `Command::new(gh_path)` subprocess | VERIFIED | `Command::new(&self.gh_path)` in all methods |
| `sqlite.rs` | V004 migration | refinery embedded migrations runner | VERIFIED | `embedded::migrations::runner().run(…)` at lines 112, 123 |
| `sqlite.rs` | V005 migration | same runner | VERIFIED | Both migrations in `/migrations/`; runner applies all |
| `live_client.rs` | `issues_client.rs` | `GhIssuesClient::list_issues_by_label("ww-product")` | VERIFIED | `live_client.rs:797` |
| `live_client.rs` | `sqlite.rs` | `delete_product` for stale cleanup | VERIFIED | `live_client.rs:862` |
| `main.rs` | `issues_client.rs` | `create_issue(…, "ww-product")` | VERIFIED | `main.rs:3375` |
| `main.rs` | `issues_client.rs` | `create_unit_as_subissue` | VERIFIED | `main.rs:3619`, `main.rs:3886` |
| `main.rs` | `product_images_branch_client.rs` | `upload_image` for Set Image | VERIFIED | `main.rs:3479` |
| `main.rs` | `discovery.rs` | `build_product_option_grid` for catalog grid | VERIFIED | `main.rs:1824`, `main.rs:1839` |
| `main.rs` | `sqlite.rs` | `read_all_products` for lookup modal | VERIFIED | `main.rs:3047`, `main.rs:3090` |
| `dashboard.slint` | `product-detail.slint` | `ProductDetailPanel` in filtered card view | VERIFIED | `dashboard.slint:6` import; `dashboard.slint:563` renders conditionally |
| `main.rs` | `product-detail-visible` | `set_product_detail_visible` on tile click | VERIFIED | `main.rs:2581`; tab switch calls `refresh_product_grid` which includes sidecar state |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|-------------------|--------|
| `option-grid.slint` (product tiles) | `ProductTileData` model | `refresh_product_grid` → `store.read_all_products()` → SQLite | Yes — SQL SELECT from `products` table | FLOWING |
| `product-detail.slint` (units list) | `ProductUnitDisplayData` model | `main.rs` unit list build after tile click → `store.read_units_by_product()` | Yes — SQL SELECT from `serial_instances` | FLOWING |
| `product-picker.slint` (lookup modal) | `LookupResultEntry` model | `main.rs:3047` `store.read_all_products()` on modal open | Yes — SQL SELECT | FLOWING |
| `product_refs` on cards | `Vec<ProductRef>` deserialized | `live_client.rs:427` from `card_row.product_refs_json` in SQLite | Yes — persisted by `main.rs:3749`–`3752` on product picker add | FLOWING |

**Note:** `build_product_option_grid` recipient count still uses `c.product_names` for matching during transition period (code comment: "During transition: match by product name"). This is a documented design decision — product_refs are stored and searchable, but recipient count computation has a card-name fallback while migration is in progress.

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| All `integrations` unit tests pass | `cargo test -p integrations --lib` | 66 passed; 0 failed | PASS |
| All `service` unit tests pass (incl. product SQLite tests) | `cargo test -p service --lib` | 24 passed including `product_row_roundtrip`, `product_unit_roundtrip`, `product_upsert_overwrites`, `read_product_by_id_returns_none_for_missing`, `card_product_refs_roundtrip` | PASS |
| All `app` unit tests pass (incl. discovery grid tests) | `cargo test -p app --lib` | `build_product_option_grid_*` tests pass | PASS |
| Full workspace compiles clean | `cargo build --workspace` | `Finished dev` with 1 warning (dead_code on `gh_issues` field — informational only) | PASS |

---

### Requirements Coverage

| Requirement | Source Plans | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| CLOUD-03 | 17-01, 17-04, 17-09 | System can create GH Issues tagged `ww-product` for product catalog entries | SATISFIED | `create_issue(…, "ww-product")` in `issues_client.rs`; wired in `main.rs:3375` |
| CLOUD-04 | 17-01, 17-04, 17-09 | System can update `ww-product` GH Issues when product data changes | SATISFIED | `edit_issue_body` in `issues_client.rs`; image update path via `edit_issue_body` in `main.rs` |
| PROD-01 | 17-02, 17-03, 17-06, 17-07, 17-09 | User can browse a standalone product list showing all hardware products | SATISFIED | `build_product_option_grid` + `refresh_product_grid`; tab persistence verified; lookup modal wired |
| PROD-02 | 17-01, 17-07 | Product entities include product_id, name, image_url, and is_serializable flag | SATISFIED | `ProductRow` struct has all fields; `upsert_product` always sets `is_serializable = 1` |
| PROD-03 | 17-05, 17-09 | Cards reference products via `product_refs` instead of freeform `item_summary` | SATISFIED | V005 migration; `ProductRef` struct; `product_refs_json` stored and deserialized; picker persists refs to SQLite |
| PROD-04 | 17-04, 17-08 | User can add serialized products nested under their parent product via the product add UI | SATISFIED | `create_unit_as_subissue` wired from sidecar; inline SN TextInput in `product-detail.slint` |
| PROD-05 | 17-04, 17-08 | User can create new serialized product instances from a parent product | SATISFIED | `on_on_product_create_unit_with_serial` callback wired; auto-generates sequential SN when input blank |

All 7 phase requirements are satisfied. No orphaned requirements found.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `main.rs` | 3558–3560 | `on_on_product_sidecar_archive` is a no-op with `eprintln!` | INFO | Product archiving intentionally deferred per code comment; does not block any phase goal |
| `main.rs` | 1846 | `has_image: false` with comment "Async image loading deferred" | INFO | Product tile images not rendered asynchronously yet; image_hint URL is populated, visual display deferred |
| `discovery.rs` | 331 | Recipient count in product grid matches by `product_names` during transition | INFO | Documented design decision ("During transition"); does not block product browsing |

No blocker or warning anti-patterns found.

---

### Human Verification Required

#### 1. Product Grid Visual Browsing

**Test:** Launch the app, switch to the Products tab (ByProductShipped mode), verify the grid shows catalog tiles with names and recipient counts.
**Expected:** Tiles from SQLite product catalog visible; switching away and back does not empty the grid.
**Why human:** UI rendering and tab persistence require running app.

#### 2. Add Product Flow

**Test:** Click "Add Product" button, fill in Name and optional Shopify URL, submit.
**Expected:** Modal closes, new tile appears in grid, ww-product GH Issue created in beyond-outgoing repo.
**Why human:** Requires live GH authentication and network call.

#### 3. Serial Unit Creation

**Test:** Click a product tile to open sidecar, click "+", type a serial number and press Enter.
**Expected:** Inline input appears, on Enter the unit appears in the list, ww-product-unit subissue created.
**Why human:** Requires live GH authentication; inline input focus behavior needs visual confirmation.

#### 4. Set Image via File Picker

**Test:** Click the product image in the sidecar, select a file in the native picker.
**Expected:** Image uploads to product-images branch, tile updates with new image.
**Why human:** Native file dialog, network call, and image rendering require human interaction.

#### 5. Shopify URL and GH Issue Link Opening

**Test:** In sidecar, click "View on Shopify" and "GH Issue" links.
**Expected:** OS browser opens the respective URLs.
**Why human:** OS `open::that()` behavior and browser launch require human observation.

---

### Gaps Summary

No gaps. All 9 observable truths verified. All 7 requirement IDs (CLOUD-03, CLOUD-04, PROD-01, PROD-02, PROD-03, PROD-04, PROD-05) satisfied with direct code evidence. Workspace compiles clean and all unit tests pass.

One informational note: product archive is explicitly a no-op (logged to stderr), but this is a known deferred feature not required by any of the phase requirements. The `product_refs` to item square rendering transition is also incomplete — refs are stored and flow through the pipeline but recipient count still uses `product_names` as a documented fallback during migration.

---

_Verified: 2026-03-26T07:06:49Z_
_Verifier: Claude (gsd-verifier)_
