---
phase: 20.2-sync-integrity-and-shipment-state-fixes
verified: 2026-04-13T12:30:00Z
status: human_needed
score: 9/10 must-haves verified
overrides_applied: 0
human_verification:
  - test: "Trigger a live sync and confirm shipment states update correctly on cards"
    expected: "Cards that had fulfillments on Shopify should show Packing/In Transit/Delivered (not Not Shipped/Unfulfilled/Label Created). Cards with no Shopify fulfillments should show Preparing."
    why_human: "The per-order fulfillment API calls require live Shopify credentials. BUGSWEEPER confirmed the migration applied and code is wired, but only runtime behavior with real Shopify data confirms tracking-event-based derivation is working end-to-end."
  - test: "Create a product from the lookup modal, then trigger a full sync refresh via F5"
    expected: "The product created from the lookup modal should still be present in the Products tab after the sync completes — it must NOT disappear."
    why_human: "D-08 (synchronous GH Issue creation) prevents the race condition, but confirming the product persists through a live sync cycle requires actual GH API interaction and a running app."
  - test: "Check REQUIREMENTS.md entries CARD-05 and SERIAL-02 for staleness"
    expected: "CARD-05 still documents old state enum (Not Shipped, Label Created) and SERIAL-02 still lists Created as an initial state. Both need updating to reflect Phase 20.2 changes (Preparing, Packing replacing old names; no Created initial state)."
    why_human: "REQUIREMENTS.md is a living document that humans maintain. Updating it requires a decision about whether these are traceability issues or acceptable documentation lag."
---

# Phase 20.2: Sync Integrity and Shipment State Fixes — Verification Report

**Phase Goal:** Fix sync-related bugs: ephemeral products from lookup modal, missing Shopify image fetches after product add, card images not populating until sync, ww-card GH issues not updating on card changes, and shipment state not updating from Shopify fulfillments.
**Verified:** 2026-04-13T12:30:00Z
**Status:** human_needed
**Re-verification:** No — initial verification

---

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Cards show 'Preparing' instead of 'Not Shipped' or 'Unfulfilled' when no fulfillments exist | VERIFIED | `derive_shipment_status_for_order` returns `Some("Preparing")` when `fulfillments.is_empty()` — test `derive_no_fulfillments_returns_preparing` passes |
| 2 | Cards show 'Packing' when a fulfillment exists with label_created tracking event | VERIFIED | `derive_shipment_status_for_order` maps `label_created` → `"Packing"` — test `derive_label_created_returns_packing` passes |
| 3 | Cards show correct shipment state derived from Shopify tracking events, not order-level fulfillment_status | VERIFIED | `live_client.rs` no longer contains `order.fulfillment_status.as_deref()` — replaced with `derive_shipment_status_for_order(&fulfillments, &tracking_events)` at line 921 |
| 4 | When shipment state changes on sync, detect_card_changes queues UpdateCardBody pending edits for ww-card GH Issues | VERIFIED | `detect_card_changes` at line 1480 diffs `old_card.shipment_status != new_card.shipment_status` and calls `store.insert_pending_edit(... "UpdateCardBody" ...)` — wired and functional |
| 5 | When a card's shipment state changes, assigned serial units cascade to matching states | VERIFIED | `cascade_shipment_state_to_units` in `assignment.rs` maps Packing/In Transit/Delivered/Return In Transit/Returned; wired in `live_client.rs` at Step 4c with change-detection guard |
| 6 | Serial units do not have a 'Created' state — initial states are Available or Assigned only | VERIFIED | `cascade_shipment_state_to_units` has no 'Created' state in its mapping; tests confirm cascade only uses Packing/In Transit/Delivered/Return In Transit/Returned; D-04 enforced in CONTEXT.md decisions |
| 7 | The serial unit state machine accepts Packing as a valid transition target from Assigned | VERIFIED | `PROMPT_FREE_STATES` does NOT contain "Packing" (line 28 in assignment.rs); test `packing_is_valid_state_in_state_machine_needs_reassignment` passes; 196 app tests pass |
| 8 | Products created from the lookup modal persist across sync cycles | VERIFIED (code) | `on_lookup_create_confirmed` in `main.rs` calls `thread::spawn(...).join()` (line 4181) — GH Issue creation is synchronous, `set_product_issue_number` called before callback returns |
| 9 | Image pipeline status determined (D-10) | VERIFIED | Plan 04 BUGSWEEPER runtime verification confirmed: 3/4 products have image URLs (CDN URLs for Shopify-linked products); Testorama has no image by design (no Shopify URL). D-10 declared PASSING. |
| 10 | End-to-end runtime verification with live Shopify data confirms new state derivation | ? NEEDS HUMAN | Plan 04 BUGSWEEPER check ran on test data (all 6 cards showed "Packing" from prior state — not verified by observing a state change from Preparing → Packing via a real tracking event) |

**Score:** 9/10 truths verified (1 needs human confirmation)

---

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `crates/service/src/db/migrations/V008__shipment_status_rename.sql` | SQLite migration renaming legacy shipment_status values | VERIFIED | Contains 5 UPDATE statements; Not Shipped→Preparing, Unfulfilled→Preparing, Label Created→Packing, Shipped→Packing, Return Initiated→Return In Transit |
| `crates/service/src/sync/shopify_projection.rs` | Updated shipment state derivation using tracking events | VERIFIED | Contains `pub fn derive_shipment_status_for_order` at line 76; contains "Packing" and "Preparing"; 7 unit tests covering all D-02 states pass |
| `crates/integrations/src/shopify/order_fulfillment_client.rs` | Fulfillment struct with line_item_product_ids | VERIFIED | `pub line_item_product_ids: Vec<String>` present at line 25 in Fulfillment struct |
| `crates/app/src/live_client.rs` | Shipment state wired via derive_shipment_status_for_order | VERIFIED | Import at line 10; call at line 921; `card_fulfillment_products` HashMap at line 914; cascade at line 1089 |
| `crates/app/src/dashboard/assignment.rs` | cascade_shipment_state_to_units + state machine | VERIFIED | `pub fn cascade_shipment_state_to_units` at line 132; `fn get_shopify_product_id_for_unit` at line 194; "Packing" in state mapping at line 140 |
| `crates/app/src/main.rs` | Synchronous ww-product GH Issue creation from lookup modal | VERIFIED | `thread::spawn(...).join()` at line 4181; `set_product_issue_number` call at line 4162 |
| `crates/service/src/db/sqlite.rs` | set_product_issue_number method | VERIFIED | `pub fn set_product_issue_number` at line 895 |

---

### Key Link Verification

| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| `live_client.rs` | `shopify_projection.rs` | `derive_shipment_status_for_order` call | WIRED | Import at line 10; called at line 921 inside order loop |
| `live_client.rs` | `shopify/http_client.rs` | `fulfillments_for_order` API call per order | WIRED | Called at line 919 inside the order iteration loop |
| `live_client.rs` | `assignment.rs` | `cascade_shipment_state_to_units` call after card upsert | WIRED | Called at line 1089 in Step 4c block with change-detection guard |
| `assignment.rs` | `sqlite.rs` | `update_unit_state` and `read_units_by_card` queries | WIRED | `read_units_by_card` called at line 149; `update_unit_state` called at line 170 |
| `main.rs` `on_lookup_create_confirmed` | `github/issues_client.rs` | synchronous GH Issue creation | WIRED | `GhIssuesClient::new()` + `create_issue(..., "ww-product")` inside joined thread at line 4155-4181 |

---

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|-------------------|--------|
| `live_client.rs` shipment status | `shipment_status` from `derive_shipment_status_for_order` | `shopify.fulfillments_for_order()` + `shopify.tracking_events_for_order()` per order | YES — Shopify REST API calls; fallback to `Some("Preparing")` when no shopify client | FLOWING |
| `assignment.rs` cascade | `unit_state` derived from `new_card_status` | `store.read_units_by_card(card_id)` from SQLite | YES — reads serial_instances table filtered by assigned_card_id | FLOWING |
| `main.rs` lookup modal product | `github_issue_number` written via `set_product_issue_number` | `GhIssuesClient::create_issue()` returns issue number | YES — synchronous GH API call, writes number before thread join returns | FLOWING |

---

### Behavioral Spot-Checks

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `derive_shipment_status_for_order` with no fulfillments returns Preparing | `cargo test -p service` (test: `derive_no_fulfillments_returns_preparing`) | PASS (44 passed) | PASS |
| `derive_shipment_status_for_order` with label_created returns Packing | `cargo test -p service` (test: `derive_label_created_returns_packing`) | PASS | PASS |
| V008 migration file exists with correct UPDATE statements | File read | EXISTS — 5 correct UPDATE statements | PASS |
| `order.fulfillment_status.as_deref()` no longer in live_client.rs | grep pattern check | NO MATCHES — broken mapping is gone | PASS |
| `cascade_shipment_state_to_units` wired in sync cycle with change-detection | grep + code read | WIRED at line 1089, change-detection guard at line 1083 | PASS |
| `thread::spawn(...).join()` used in on_lookup_create_confirmed | code read | CONFIRMED at line 4181 | PASS |
| All app tests pass (196) | `cargo test --lib -p app` | 196 passed, 0 failed | PASS |
| All service tests pass (44) | `cargo test --lib -p service` | 44 passed, 0 failed | PASS |
| All integrations tests pass (85) | `cargo test --lib -p integrations` | 85 passed, 0 failed | PASS |

---

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| D-01 | Plan 01 | Replace broken order-level fulfillment_status mapping | SATISFIED | `order.fulfillment_status.as_deref()` removed; `derive_shipment_status_for_order` wired |
| D-02 | Plan 01 | Card shipment states: Preparing/Packing/In Transit/Delivered | SATISFIED | All 4 states in `derive_shipment_status_for_order`, unit tests pass |
| D-03 | Plan 01 | SQLite V008 migration renaming legacy values | SATISFIED | `V008__shipment_status_rename.sql` exists with correct UPDATE statements |
| D-04 | Plan 02 | Serial units do not have 'Created' state; initial states Available or Assigned | SATISFIED | No 'Created' in cascade mapping; BUGSWEEPER confirmed 0 units in Created state |
| D-05 | Plan 02 | Updated serial unit state machine with Packing | SATISFIED | Packing in cascade mapping; NOT in PROMPT_FREE_STATES; test confirms prompt-requiring |
| D-06 | Plan 02 | Product-aware cascade via Shopify product ID matching | SATISFIED | `get_shopify_product_id_for_unit` extracts ID from shopify_product_url; HashSet matching in cascade |
| D-07 | Plan 02 | Card→unit state mapping (Preparing→no change) | SATISFIED | `match new_card_status { "Packing" => ..., _ => return }` at line 138-146 in assignment.rs |
| D-08 | Plan 03 | Synchronous ww-product GH Issue creation from lookup modal | SATISFIED | `thread::spawn(...).join()` confirmed; `set_product_issue_number` called before return |
| D-09 | Plan 01 | ww-card GH Issue updates when card data changes | SATISFIED | `detect_card_changes` diffs `shipment_status` and queues `UpdateCardBody` pending edits |
| D-10 | Plan 04 | Image pipeline status determined | SATISFIED | BUGSWEEPER confirmed 3/4 products have image URLs; 1 product (Testorama) has no image by design — PASSING |

**Note on REQUIREMENTS.md staleness:** Two formal requirements reference superseded state names:
- **CARD-05** still documents `Not Shipped`, `Label Created` — Phase 20.2 replaced these with `Preparing`, `Packing`
- **SERIAL-02** still lists `Created` as an initial state — Phase 20.2 (D-04) established that serial units never have a Created state

These are documentation-only issues in REQUIREMENTS.md. The implementation is correct. Updating REQUIREMENTS.md is recommended in a follow-up commit.

---

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `crates/service/src/sync/shopify_projection.rs` | 55-60 | `normalize_fulfillment_status` still uses legacy string "Shipped" in its return value | INFO | This older function is retained for backward compatibility but is no longer the primary derivation path — `derive_shipment_status_for_order` is used instead. No user-visible impact. |
| `crates/app/src/main.rs` | 4125-4136 | Product written to SQLite with `github_issue_number: None` then issue created async (even though joined) | INFO | The product exists in SQLite with `None` issue number during the window between upsert and thread join. The join ensures the number is written before anything can sync, so this is safe per D-08 design. |

No blockers found.

---

### Human Verification Required

#### 1. Live Sync — Shipment State Derivation from Real Shopify Tracking Events

**Test:** Run the app, wait for a full sync to complete, then inspect card shipment states. For at least one card that has a Shopify order with a real fulfillment, the state should reflect the fulfillment's tracking event.
**Expected:** Cards with no Shopify fulfillments show "Preparing". Cards with a label_created tracking event show "Packing". Cards with in_transit show "In Transit". No legacy state names (Not Shipped, Unfulfilled, Label Created, Shipped) appear anywhere.
**Why human:** Plan 04 BUGSWEEPER runtime verification used test data where all 6 existing cards already showed "Packing" from a prior state. A state *transition* from Preparing → Packing via a real Shopify tracking event was not observed live. Confirming the tracking-event API path produces the correct result requires live Shopify credentials.

#### 2. Lookup Modal Product Persistence

**Test:** In the running app, open the lookup modal and create a new product. Then press F5 to trigger a full sync. After sync completes, verify the product is still present in the Products tab.
**Expected:** The product created from the lookup modal should persist through the sync cycle. Before Phase 20.2, it could disappear if sync ran before the GH Issue number was written.
**Why human:** The fix (D-08) is synchronous code verified at the level of `thread.join()`, but confirming the product survives stale-product cleanup requires running the actual sync pipeline with a real GH Issue creation.

#### 3. REQUIREMENTS.md Update

**Test:** Review CARD-05 and SERIAL-02 in `.planning/REQUIREMENTS.md`.
**Expected:** CARD-05 should be updated to reflect new state enum (Preparing, Packing, In Transit, Delivered, Return In Transit, Returned). SERIAL-02 should be updated to remove "Created" and clarify initial states are Available (unassigned) or Assigned.
**Why human:** REQUIREMENTS.md is a human-maintained traceability document. The decision to update it (or to accept the mismatch as documentation lag) belongs to the project owner.

---

### Gaps Summary

No code gaps found. All 10 phase requirements (D-01 through D-10) are implemented, tested, and wired. The three human verification items are runtime/documentation checks that cannot be confirmed programmatically.

**Automated verification summary:**
- 44 service tests pass (includes 7 new derive_shipment_status_for_order TDD tests)
- 85 integrations tests pass (includes 2 new line_item_product_ids parser tests)
- 196 app tests pass (includes 8 new cascade + state machine tests)
- V008 migration file exists with correct content
- Broken `order.fulfillment_status` mapping fully removed from live_client.rs
- `detect_card_changes` diffs `shipment_status` and queues `UpdateCardBody` pending edits
- `cascade_shipment_state_to_units` wired with change-detection guard, product-aware matching, and GH write-back
- `on_lookup_create_confirmed` uses `thread.join()` for synchronous GH Issue creation

---

_Verified: 2026-04-13T12:30:00Z_
_Verifier: Claude (gsd-verifier)_
