# Phase 20.2: Sync Integrity and Shipment State Fixes - Research

**Researched:** 2026-04-13
**Domain:** Rust sync pipeline, Shopify fulfillment integration, SQLite migration, GH Issues write-back
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Replace broken `order.fulfillment_status` mapping (live_client.rs:915-921) with per-fulfillment tracking events via `shopify_projection.rs`.
- **D-02:** Card shipment states: Preparing (no fulfillment), Packing (label_created tracking), In Transit (in_transit tracking), Delivered (delivered tracking), Return In Transit (reverse fulfillment in_transit), Returned (reverse fulfillment delivered).
- **D-03:** SQLite migration renames existing shipment_status values (Not Shipped→Preparing, Label Created→Packing, etc.). Clean cutover.
- **D-04:** When card shipment state changes, assigned serial units cascade to matching states. Serial units do NOT have a "Created" state. Initial states: Available (unassigned) or Assigned (on a card).
- **D-05:** Updated serial unit state machine: Available → Assigned → Packing (NEW) → In Transit → Delivered → Return In Transit → Returned → Available.
- **D-06:** Cascade is product-aware: only serial units whose Shopify product appears in the relevant fulfillment get the state change, PLUS units with no associated Shopify product (always cascade). Units whose product is missing from the fulfillment stay at their current state.
- **D-07:** Card state → serial unit state mapping: Preparing→no change, Packing→Packing, In Transit→In Transit, Delivered→Delivered, Return In Transit→Return In Transit, Returned→Returned.
- **D-08:** When creating a product from the lookup modal, immediately create the ww-product GH Issue synchronously (same pattern as product catalog view). Prevents vanishing on next sync when stale-product cleanup runs.
- **D-09:** When `detect_card_changes` finds a diff (product_refs, shipment_status, shipment_status_date), queue a pending edit to update the existing ww-card GH Issue body.
- **D-10:** Research phase must verify whether Shopify image fetch and card image display bugs still exist. If already fixed in Phase 20.1, drop from scope.

### Claude's Discretion

- Exact implementation of fulfillment-to-card-state mapping (which Shopify API fields to use for each state)
- How to match serial units to fulfillment line items by Shopify product
- SQLite migration strategy for adding "Packing" to serial unit states
- PendingEdit type for ww-card body updates (new edit_type discriminator)
- Whether reverse fulfillment tracking needs additional API calls or can reuse existing GraphQL client

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope.
</user_constraints>

---

## Summary

Phase 20.2 is a focused bug-fix phase with five distinct problems, all rooted in the sync pipeline. The most impactful fix is replacing the broken order-level `fulfillment_status` mapping with proper tracking-event-based state derivation — the existing `shopify_projection.rs` module has most of the logic ready but is never called from `run_sync_cycle`. The `Fulfillment` and `TrackingEvent` structs need to be extended to carry line-item product IDs to enable the product-aware serial unit cascade.

The ephemeral product bug from the lookup modal already has a background-thread GH Issue creation path in `main.rs` (lines 4139-4175). However, the race condition is that `sync_products` stale-cleanup runs on the next sync cycle, and if the background thread hasn't written `github_issue_number` back to SQLite yet, the product gets deleted. The fix per D-08 is to make GH Issue creation synchronous on a spawned thread that writes back before returning — or alternatively to protect locally-created products by checking `github_issue_number IS NULL` in the stale-cleanup logic (products with no issue number are already protected in `sync_products` line 1191).

Re-reading the stale cleanup code confirms: line 1191 already says `if product.github_issue_number.is_some()` — so locally-created products (no issue number yet) are already excluded from deletion. The actual bug must be that the background thread is creating the GH issue and writing the issue number back to SQLite, which then makes the product eligible for deletion on the next sync when it's not yet in GH Issues. Wait — the issue is the opposite: the product IS created with a GH issue, but the product ID in the ww-product issue body is a UUID generated locally. When `sync_products` runs and fetches all `ww-product` issues, it upserts them by `product_id` from the body — that UUID won't match any Shopify product. The issue is likely timing: if the background thread hasn't written the issue number back yet AND another sync runs, the product gets cleaned up because `github_issue_number IS NULL` AND it's not in `synced_product_ids`. Actually the code protects against this — `github_issue_number IS NONE` products are preserved. So the actual bug for D-08 needs further investigation during implementation.

The ww-card GH Issue update bug (D-09) is confirmed: `detect_card_changes` already queues `UpdateCardBody` and `UpdateCardTitle` pending edits for changed cards. Reviewing the code again — `detect_card_changes` IS implemented and does queue updates (lines 1445-1508). The bug must be that `detect_card_changes` is not being called, or `old_cards` snapshot is taken at the wrong point. Investigation target: whether old_cards includes the pre-upsert state correctly.

**Primary recommendation:** Wire `shopify_projection.rs` into `run_sync_cycle` to replace lines 915-921; extend `Fulfillment` to carry `line_item_product_ids: Vec<String>`; add V008 migration for state renames; add cascade function in `assignment.rs`; confirm image bug status via BUGSWEEPER before planning image fix tasks.

---

## Project Constraints (from CLAUDE.md)

- **Windows only** — all shell commands must use Windows-compatible syntax (bash shell available, but no `jq`)
- **Read `code_tips/` before modifying** SQLite or Slint code
- **Read `DATA-FLOW.md`** before touching data structs, sync logic, storage, or field definitions
- **BUGSWEEPER verification** — all fixes must be verified using BUGSWEEPER + screenshot skill
- **SQLite single read source** (RULE-03) — never read from in-memory Repository in production
- **GH Issues are cloud of record** (RULE-05) — ww-card and ww-product issues must stay in sync
- **No `github_profile_url`** (RULE-01), no shipment fields on Recipient (RULE-02)
- `INSERT OR REPLACE` is forbidden — use `ON CONFLICT DO UPDATE` (see SQLITE_TIPS.md)
- **`commit_docs: true`** — commit documentation files

---

## Architecture Patterns

### Existing Sync Pipeline Structure (run_sync_cycle)

```
Step 1: Fetch GH Project rows → build customer_id→recipient_idx map
Step 2: Fetch Shopify orders tagged "wit-what" via orders_by_tag()
Step 3+4: Match orders to recipients → build RecipientCardSnapshot vec
           ↳ BROKEN: shipment_status derived from order.fulfillment_status (lines 915-921)
Step 4: Upsert matched cards to SQLite; snapshot old_cards before upserts
Step 4b: Upsert recipients to SQLite
Step 5: sync_products() — GH issues → SQLite, stale cleanup, Shopify image fetch
Step 5b: detect_card_changes(old_cards, new_cards) → queue UpdateCardBody/UpdateCardTitle
Step 5c: sync_card_issues() — create ww-card issues for cards without one
Step 5d: sync_return_states() via GraphQL — handles Return In Transit / Returned states
```

### Shipment State Derivation — What Needs to Change

**Current (broken):** `order.fulfillment_status` field from orders JSON is mapped inline at lines 915-921. `"fulfilled"` → "Delivered" is wrong — it just means fulfillment records exist.

**Target:** Call `shopify_projection.rs::project_shipment_for_customer()` per order, or inline equivalent logic using `fulfillments_for_order()` + `tracking_events_for_order()` from `HttpShopifyClient`. The projection must be called per order (not per customer, since we have orders directly).

**Key insight:** `shopify_projection.rs` defines `project_for_order()` which accepts a `&dyn ShopifyOrderFulfillmentClient` and an order. `HttpShopifyClient` implements `ShopifyOrderFulfillmentClient`. The sync cycle already holds `shopify_client: Option<&HttpShopifyClient>`. The wiring is straightforward.

**State mapping from tracking events:**
- No fulfillments → `"Preparing"` (replaces "Not Shipped")
- Tracking event `"label_created"` → `"Packing"` (replaces "Label Created")
- Tracking event `"in_transit"` → `"In Transit"`
- Tracking event `"delivered"` → `"Delivered"`
- Reverse fulfillment in_transit → `"Return In Transit"`
- Reverse fulfillment delivered → `"Returned"`

**Important:** `parse_tracking_events_response` in `http_client.rs` reads from `tracking_details[*].message` or `.status` fields. The actual Shopify REST API tracking event states returned in these fields need verification — the current `normalize_tracking_state()` in `shopify_projection.rs` maps `"label_created"`, `"in_transit"`, `"delivered"`. The new `"Packing"` state maps to `"label_created"`. [ASSUMED — Shopify API field values based on existing code patterns]

### Fulfillment Struct Extension for Product-Aware Cascade

The current `Fulfillment` struct only has `order_id` and `status`:

```rust
// crates/integrations/src/shopify/order_fulfillment_client.rs
pub struct Fulfillment {
    pub order_id: String,
    pub status: String,
}
```

For product-aware cascade (D-06), the struct needs the Shopify product IDs included in each fulfillment. Shopify's REST API returns `line_items` on each fulfillment object, and each line item has a `product_id` field.

**Required extension:**
```rust
pub struct Fulfillment {
    pub order_id: String,
    pub status: String,
    pub line_item_product_ids: Vec<String>,  // NEW: Shopify product IDs in this fulfillment
}
```

The `parse_fulfillments_response()` parser in `http_client.rs` must be updated to extract `line_items[*].product_id` from the Shopify JSON. [ASSUMED — Shopify REST API fulfillments endpoint includes line_items array with product_id per item, based on Shopify API documentation patterns]

### Product-Aware Cascade Implementation

The cascade runs after card shipment state is updated in SQLite during `run_sync_cycle`. The matching logic:

1. For each updated card with a new shipment state:
2. Get the fulfillments for that card's Shopify order
3. Collect the set of Shopify product IDs across all fulfillments for that order
4. Get all serial units assigned to that card
5. For each unit:
   - If unit's product has `shopify_product_url` that matches a product in the fulfillment set → cascade state
   - If unit's product has no `shopify_product_url` (no Shopify association) → always cascade
   - If unit's product has a `shopify_product_url` but the Shopify product is NOT in the fulfillment → no change

**Matching mechanism:** Serial units belong to a `product_id` (UUID). Products have a `shopify_product_url` (e.g. `https://{shop}.myshopify.com/admin/products/{shopify_id}`). Fulfillment line items carry a numeric `product_id` (Shopify's). The matching bridges these: extract the numeric ID from `shopify_product_url` and compare to fulfillment line item product IDs.

**New SQLite query needed:** `SELECT * FROM serial_instances WHERE assigned_card_id = ?` to get all units on a card. Already exists as `read_units_for_card()` pattern (check SqliteStore).

### Serial Unit State Machine Update

Current states in code (from assignment.rs tests): Available, Assigned, In Transit, Delivered, Return Initiated, Return In Transit, Returned, Processing.

New states after D-05: Available → Assigned → **Packing (NEW)** → In Transit → Delivered → Return In Transit → Returned → Available.

`PROMPT_FREE_STATES` in `assignment.rs` currently: `["Available", "Returned", "Processing"]`. "Processing" is a legacy state — keep it for safety. "Packing" should be added to the prompt-required states (not prompt-free), since it means the unit is actively in a fulfillment.

**V008 Migration needed:**
- Rename shipment_status values in `cards` table
- Add "Packing" as valid state in `serial_instances` (no constraint to change — state is a TEXT column)
- Optionally rename any legacy states in `serial_instances`

### ww-card GH Issue Updates (D-09)

`detect_card_changes()` already exists and already queues `UpdateCardBody` and `UpdateCardTitle` pending edits (lines 1448-1508 in live_client.rs). The implementation is complete. The bug to investigate: is `detect_card_changes` actually being called with the right `old_cards`?

Reviewing `run_sync_cycle`: `old_cards` is snapshotted at line 987 (`let old_cards = store.read_all_cards().unwrap_or_default()`), then cards are upserted in the loop (lines 988-1031), and `detect_card_changes` is called at line 1081 with `&old_cards` and `&new_cards` (re-read from store). This is correct — the D-09 fix is already in the codebase.

**Investigation target:** If D-09 is already working, the actual bug may be that detect_card_changes only queues edits for cards that already have a `github_issue_number`. New cards get their issue created at Step 5c (`sync_card_issues`), which runs AFTER `detect_card_changes`. So the first time a card gets an issue, the body written at creation time IS the current body. The bug may be that `shipment_status` changes after issue creation (e.g. Shopify updates a fulfillment) are not triggering `detect_card_changes` updates — this depends on whether `old_cards.shipment_status != new_cards.shipment_status`. Since shipment status is being derived from the broken order-level field (lines 915-921), it may always match the stored value, preventing `detect_card_changes` from detecting a real change. **Fix for D-09 is a consequence of fixing D-01** — once shipment status is correctly derived, changes will propagate to GH Issues automatically through the existing mechanism.

### Ephemeral Products from Lookup Modal (D-08)

The `on_lookup_create_confirmed` callback (main.rs ~4113) already:
1. Writes product to SQLite with `github_issue_number: None`
2. Spawns background thread to create ww-product GH Issue

The stale-product cleanup in `sync_products` (line 1191) already protects products with `github_issue_number IS NULL`:
```rust
if product.github_issue_number.is_some() && !synced_product_ids.contains(&product.product_id)
```

So locally-created products (no issue number yet) should NOT be deleted. The actual ephemeral bug is likely:
- The background thread succeeds and writes `github_issue_number` to SQLite
- On the next sync, `sync_products` fetches the ww-product GH issue
- The issue body contains the UUID `product_id` generated locally
- `sync_products` upserts with that product_id — should match the existing row
- **But if the product_id in the issue body doesn't match** (e.g. if the body wasn't written correctly), a new row is created and the old one gets cleaned up

D-08 says the fix is to make GH Issue creation synchronous (same thread, not background). This is the correct fix — it ensures the issue exists and the number is written before the function returns, making the product durable regardless of timing.

**Implementation pattern to follow:** The product catalog view's `on_add_product_submit` (main.rs ~4291) creates the ww-product GH Issue synchronously via a background thread that blocks on the result. Match that pattern.

Actually, looking at `on_add_product_submit` at line 4291-4343: it also uses `std::thread::spawn` (fire-and-forget). So "synchronous" per D-08 means: same pattern as the product catalog add, which is already how the lookup modal works. The actual fix for D-08 may be simpler — checking whether the race condition is real or if the existing protection is sufficient.

**Recommendation:** During implementation, first verify via BUGSWEEPER whether ephemeral products actually still vanish. If the stale-cleanup protection is working, D-08 may be resolved. If not, investigate the race.

### Image Pipeline Status (D-10 — Verify First)

The image pipeline in `sync_products()` (lines 1202-1306) is fully implemented:
- Fetches Shopify CDN URL for products with `shopify_product_url` but no `image_url`
- Downloads to local cache via `product_image_cache::download_product_image()`
- Updates SQLite with the CDN URL
- Updates GH Issue body with the image URL

The `get_or_load_product_image()` function in main.rs (line 31) loads images from the in-memory thread-local cache, falling back to disk.

The debug log lines (`eprintln!("[sync_products] shopify_client is_some=..."` etc. at lines 1204-1242) were added during Phase 20.1 UAT. This strongly suggests the image fetch was being debugged and may have been fixed.

**D-10 action:** Run BUGSWEEPER smoke test to check if products display images after sync. Only plan image fix tasks if the bug is confirmed present.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Shopify fulfillment state projection | Custom mapping logic | `shopify_projection.rs::project_for_order()` | Already tested, handles all edge cases |
| Pending edit queue | New queue mechanism | `store.insert_pending_edit()` + `pending_edit_flusher::notify()` | Established pattern with backoff, dedup |
| GH Issue body updates | Direct API calls | `PendingEditFlusher` with `UpdateCardBody` edit_type | Retry, backoff, offline handling |
| SQLite migrations | Manual ALTER TABLE | refinery `V008__*.sql` migration file | Automatic, versioned, tested |
| Reverse fulfillment tracking | New HTTP client | `ShopifyGraphqlClient::reverse_fulfillment_tracking()` + `returns_for_order()` | Already implemented and tested |
| Unit state write-back to GH | New write path | `pending_edit_flusher::queue_unit_state_edit()` | Established function, handles issue lookup |

---

## Common Pitfalls

### Pitfall 1: INSERT OR REPLACE Wipes github_issue_number

**What goes wrong:** Using `INSERT OR REPLACE` or `REPLACE INTO` on cards/products/serial_instances deletes the row and reinserts it, setting `github_issue_number` to NULL.
**Why it happens:** INSERT OR REPLACE is a DELETE + INSERT at the SQLite level.
**How to avoid:** All upserts must use `ON CONFLICT(pk) DO UPDATE SET` listing only the columns that should change. `github_issue_number` is managed by `set_card_issue_number()` separately and must never be in upsert SET clauses.
**Warning signs:** Issue numbers disappear after sync cycles.

### Pitfall 2: old_cards Snapshot Timing

**What goes wrong:** `detect_card_changes` compares old vs. new cards. If `old_cards` is read after upserts rather than before, no changes are detected.
**Why it happens:** The snapshot must occur before the upsert loop (line 987 in live_client.rs) — this is currently correct. Don't move it.
**How to avoid:** Keep the `old_cards = store.read_all_cards()` call before the upsert loop.

### Pitfall 3: TrackingEvent State Strings

**What goes wrong:** `parse_tracking_events_response()` reads from `tracking_details[*].message` OR `.status`, not from a normalized enum. The raw strings from Shopify may not match the expected values (`"label_created"`, `"in_transit"`, `"delivered"`).
**Why it happens:** Shopify REST API tracking_details has variable fields.
**How to avoid:** Add comprehensive mapping in `normalize_tracking_state()` in `shopify_projection.rs` that handles Shopify's actual string values. Test against real API responses or add fallback to "Preparing" for unknown states.
**Warning signs:** Cards stuck in "Preparing" even when fulfillments exist.

### Pitfall 4: Fulfillment HTTP Call Per Order (Performance)

**What goes wrong:** Replacing the order-level `fulfillment_status` with per-order API calls means one `GET /orders/{id}/fulfillments.json` call per order per sync cycle. With many orders, this multiplies API calls significantly.
**Why it happens:** The current approach reads `fulfillment_status` from the orders batch response (no extra calls). Per-fulfillment tracking requires a separate endpoint.
**How to avoid:** Make this non-blocking/non-fatal and add a log for API call count. Consider rate limiting (Shopify REST: 2 req/sec bucket). Shopify REST API returns `tracking_details` inside the fulfillments response, so one call per order fetches both fulfillments AND tracking events.
**Warning signs:** Sync cycle takes much longer; Shopify 429 rate-limit responses.

### Pitfall 5: Product-Aware Cascade Requires Shopify Product ID Bridging

**What goes wrong:** Serial units reference products by UUID (`product_id`). Fulfillment line items carry Shopify's numeric product ID. There's no direct join — you must bridge through `products.shopify_product_url`.
**Why it happens:** Product IDs are UUIDs internally; Shopify uses numeric IDs.
**How to avoid:** Extract the numeric Shopify product ID from `products.shopify_product_url` (last path segment) and compare to fulfillment `line_items[*].product_id`. Cache the mapping for the sync cycle.

### Pitfall 6: PROMPT_FREE_STATES Must Include "Packing"

**What goes wrong:** "Packing" is a new state. If not added to the right set in `assignment.rs`, units in Packing state may be incorrectly treated as prompt-free (allowing silent reassignment) or excluded from cascade.
**Why it happens:** `PROMPT_FREE_STATES` and cascade logic are separate concerns.
**How to avoid:** "Packing" should NOT be in `PROMPT_FREE_STATES` (it requires a reassignment prompt). The cascade function must handle "Packing" as a valid cascade target.

### Pitfall 7: Slint ThreadLocal Image Cache Must Be Invalidated

**What goes wrong:** The in-memory `PRODUCT_IMAGE_CACHE` (thread_local! in main.rs) caches loaded `slint::Image` objects. After a product image URL is updated by sync, the old (or absent) image stays cached until app restart.
**Why it happens:** Thread-local caches are never invalidated by background sync writes.
**How to avoid:** After `sync_products` updates a product's `image_url`, the main thread's cache must be invalidated. Since `PRODUCT_IMAGE_CACHE` is thread-local on the UI thread and sync runs on a background thread, invalidation must happen on the UI thread via `slint::invoke_from_event_loop`. However, the simpler fix (if image display is the only symptom) is to always re-check the DB at card render time rather than relying solely on the in-memory cache.

---

## Code Examples

### Wiring shopify_projection into run_sync_cycle

```rust
// [VERIFIED: live_client.rs lines 912-921] — REPLACE THIS:
let shipment_status = Some(match order.fulfillment_status.as_deref() {
    Some("fulfilled") => "Delivered".to_string(),
    // ...
});

// WITH THIS (call per-order fulfillment fetch):
let shipment_status = if let Some(shopify) = shopify_client {
    derive_card_shipment_status(shopify, &order.order_id)
} else {
    None
};

// New function using existing HTTP client:
fn derive_card_shipment_status(
    shopify: &HttpShopifyClient,
    order_id: &str,
) -> Option<String> {
    let fulfillments = shopify.fulfillments_for_order(order_id);
    let tracking_events = shopify.tracking_events_for_order(order_id);
    // Uses updated normalize functions from shopify_projection.rs
    derive_status_from_tracking(&fulfillments, &tracking_events)
}
```

### New State Normalization (shopify_projection.rs update)

```rust
// [ASSUMED — mapping based on D-02 decisions]
fn derive_status_from_tracking(
    fulfillments: &[Fulfillment],
    tracking_events: &[TrackingEvent],
) -> Option<String> {
    if fulfillments.is_empty() {
        return Some("Preparing".to_string());
    }
    // Use most recent tracking event as authoritative state
    if let Some(last_event) = tracking_events.last() {
        return Some(match last_event.state.as_str() {
            "delivered" => "Delivered".to_string(),
            "in_transit" => "In Transit".to_string(),
            "label_created" => "Packing".to_string(),
            _ => "Preparing".to_string(),
        });
    }
    // Fulfillments exist but no tracking events yet
    Some("Packing".to_string())
}
```

### V008 Migration Pattern

```sql
-- V008: Rename shipment_status values and add Packing to serial unit states (D-02, D-03, D-05)
-- Rename card shipment_status values to new names
UPDATE cards SET shipment_status = 'Preparing'        WHERE shipment_status = 'Not Shipped';
UPDATE cards SET shipment_status = 'Preparing'        WHERE shipment_status = 'Unfulfilled';
UPDATE cards SET shipment_status = 'Packing'          WHERE shipment_status = 'Label Created';
UPDATE cards SET shipment_status = 'In Transit'       WHERE shipment_status = 'Shipped';
UPDATE cards SET shipment_status = 'Return In Transit' WHERE shipment_status = 'Return Initiated';
-- serial_instances: no rename needed for existing states (Packing is new, existing valid states stay)
```

### Cascade Implementation Pattern

```rust
// [ASSUMED — implementation approach based on D-06/D-07]
// New function in assignment.rs or live_client.rs:
pub fn cascade_shipment_state_to_units(
    store: &SqliteStore,
    card_id: &str,
    new_card_status: &str,
    fulfillment_shopify_product_ids: &HashSet<String>,  // product IDs in this fulfillment
) {
    let unit_state = match new_card_status {
        "Packing"          => "Packing",
        "In Transit"       => "In Transit",
        "Delivered"        => "Delivered",
        "Return In Transit" => "Return In Transit",
        "Returned"         => "Returned",
        _ => return, // "Preparing" → no cascade
    };
    let units = store.read_units_for_card(card_id).unwrap_or_default();
    for unit in units {
        let should_cascade = if let Some(ref prod) = get_product_shopify_id(store, &unit.product_id) {
            fulfillment_shopify_product_ids.contains(prod)
        } else {
            true // no Shopify product association → always cascade
        };
        if should_cascade && unit.state != unit_state {
            let _ = store.update_unit_state(&unit.serial_id, unit_state, unit.assigned_card_id.as_deref(), unit.assigned_at.as_deref());
            pending_edit_flusher::queue_unit_state_edit(store, &unit.serial_id, unit_state, unit.assigned_card_id.as_deref());
        }
    }
}
```

### BUGSWEEPER Verification Pattern

```bash
# Build with BUGSWEEPER enabled
cargo build --features bugsweeper

# Wait for app ready
for i in $(seq 1 60); do curl -s http://127.0.0.1:9876/api/debug/health && break; sleep 0.5; done

# Check card shipment statuses after sync
curl -s "http://127.0.0.1:9876/api/data/query?sql=SELECT+card_id,+shipment_status,+shipment_status_date+FROM+cards+ORDER+BY+shipment_status" 

# Verify serial unit states
curl -s "http://127.0.0.1:9876/api/data/query?sql=SELECT+serial_id,+state,+assigned_card_id+FROM+serial_instances+WHERE+assigned_card_id+IS+NOT+NULL"

# Check pending edits queue
curl -s "http://127.0.0.1:9876/api/data/query?sql=SELECT+edit_type,+entity_id,+retry_count+FROM+pending_edits+ORDER+BY+created_at+DESC"

# Check product image_url presence
curl -s "http://127.0.0.1:9876/api/data/query?sql=SELECT+name,+image_url+IS+NOT+NULL+as+has_image+FROM+products"

# Visual check
$SKILL_PATH = "C:\Users\decid\.claude\skills\screenshot-capture"
powershell -ExecutionPolicy Bypass -File "$SKILL_PATH\scripts\capture.ps1" -Window "WITwhat" -Output after-sync.png
```

---

## Runtime State Inventory

This phase involves data-model renames and new states in SQLite. Not a user-facing rename, but SQLite data migration applies.

| Category | Items Found | Action Required |
|----------|-------------|-----------------|
| Stored data | `cards.shipment_status` column — existing values: "Not Shipped", "Label Created", "In Transit", "Delivered", "Return In Transit", "Returned", "Unfulfilled", "Shipped" | V008 migration: UPDATE statements to rename to new enum values |
| Stored data | `serial_instances.state` column — existing values: "Available", "Assigned", "In Transit", "Delivered", "Return In Transit", "Returned", "Processing" | "Packing" is new (no existing rows to rename); no rename needed |
| Live service config | None — no external service stores shipment state strings | — |
| OS-registered state | None | — |
| Secrets/env vars | None — no env vars reference state string values | — |
| Build artifacts | None relevant | — |

**Important:** The V007 migration already renamed "Created" → "Available" for serial_instances. The pending V008 migration must handle the card-level shipment_status rename and is the only data change needed.

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `order.fulfillment_status` for card state | Per-fulfillment tracking events | This phase | Cards will show correct granular states |
| "Not Shipped", "Label Created" state labels | "Preparing", "Packing" labels | This phase (D-03) | V008 migration required |
| No serial unit cascade from card state | Product-aware cascade | This phase (D-04/D-06) | Unit states auto-update on fulfillment |
| Background thread for ww-product issue creation from lookup | Synchronous creation (TBD after investigation) | This phase (D-08) | Prevents race condition if confirmed |

**Already implemented (no changes needed):**
- `detect_card_changes()` queues `UpdateCardBody`/`UpdateCardTitle` pending edits — complete
- `PendingEditFlusher` handles `UpdateCardBody` and `UpdateCardTitle` edit types — complete
- `sync_return_states()` via GraphQL handles "Return In Transit"/"Returned" from reverse fulfillments — complete
- Shopify image auto-fetch in `sync_products()` — appears complete, verify via BUGSWEEPER

---

## Open Questions

1. **Image pipeline bug status (D-10)**
   - What we know: `sync_products()` has full image fetch logic with debug logging added during 20.1 UAT
   - What's unclear: Whether images are actually appearing on cards in the UI after sync
   - Recommendation: Run BUGSWEEPER smoke test before planning any image tasks. Check `SELECT name, image_url FROM products` via BUGSWEEPER data query.

2. **Shopify tracking_details field names**
   - What we know: `parse_tracking_events_response()` reads `.message` or `.status` from `tracking_details` array items. The strings mapped in `normalize_tracking_state()` are `"label_created"`, `"in_transit"`, `"delivered"`.
   - What's unclear: Whether these match what Shopify actually returns (could be "Label Created", "IN_TRANSIT", etc.)
   - Recommendation: Test against real Shopify data during implementation. Add a catch-all that logs unrecognized tracking states.

3. **read_units_for_card() existence in SqliteStore**
   - What we know: `assignment.rs` calls `store.read_unit_by_id()`. The cascade needs all units on a card.
   - What's unclear: Whether `read_units_for_card(card_id: &str)` exists in SqliteStore or needs to be added.
   - Recommendation: Check SqliteStore implementation before planning the cascade task. If missing, add it as part of the cascade task.

4. **Fulfillment line_item_product_ids in Shopify REST response**
   - What we know: Shopify REST `GET /orders/{id}/fulfillments.json` returns fulfillments with `line_items` array. Each item has `product_id`.
   - What's unclear: Exact field path in JSON — is it `fulfillments[*].line_items[*].product_id`?
   - Recommendation: [ASSUMED based on Shopify API patterns] — verify during `parse_fulfillments_response()` update.

---

## Validation Architecture

`workflow.nyquist_validation` is absent from config.json — treated as enabled.

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in test (`cargo test`) |
| Config file | `Cargo.toml` per crate |
| Quick run command | `cargo test -p service -p app -p integrations 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map
| Req | Behavior | Test Type | Automated Command | File Exists? |
|-----|----------|-----------|-------------------|-------------|
| D-01 | `derive_card_shipment_status()` returns correct states from tracking events | unit | `cargo test -p service sync::shopify_projection` | Partially (existing normalize tests) |
| D-02 | State enum values: Preparing/Packing/In Transit/Delivered/Return In Transit/Returned | unit | `cargo test -p integrations` | ❌ Wave 0 |
| D-03 | V008 migration renames old values correctly | unit | `cargo test -p service db::sqlite` | ❌ Wave 0 |
| D-04/D-06 | cascade_shipment_state_to_units() applies product-aware cascade | unit | `cargo test -p app dashboard::assignment` | ❌ Wave 0 |
| D-08 | Lookup modal product survives sync (no ephemeral delete) | integration | Manual BUGSWEEPER | — |
| D-09 | detect_card_changes queues UpdateCardBody when shipment_status changes | unit | `cargo test -p app live_client` | Partially |

### Sampling Rate
- **Per task commit:** `cargo test -p service -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
- [ ] Tests for new `"Packing"` state in `normalize_tracking_state()` — covers D-02
- [ ] Tests for V008 migration renames — covers D-03  
- [ ] Tests for `cascade_shipment_state_to_units()` with product-aware matching — covers D-04/D-06
- [ ] `read_units_for_card()` if not present in SqliteStore — required by cascade

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust/Cargo | All compilation | ✓ | Assumed present | — |
| BUGSWEEPER | Verification steps | ✓ | Build with `--features bugsweeper` | Screenshot-only |
| screenshot-capture skill | Visual verification | ✓ | `C:\Users\decid\.claude\skills\screenshot-capture` | — |
| Shopify REST API | Fulfillment fetch | ✓ (configured) | 2024-01 | Degrade gracefully |
| Shopify GraphQL API | Return tracking | ✓ (configured) | 2026-01 | Skip return states |
| GitHub Issues API | ww-card / ww-product | ✓ (configured) | Via gh CLI | Queue in pending_edits |

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Shopify REST `fulfillments` response includes `tracking_details[*].message`/`.status` with values `"label_created"`, `"in_transit"`, `"delivered"` | Architecture Patterns, TrackingEvent | Cards would stay at "Preparing" even when shipped |
| A2 | Shopify REST `fulfillments` response includes `line_items[*].product_id` for product-aware cascade | Fulfillment Struct Extension | Cascade would need a different matching approach |
| A3 | `read_units_for_card(card_id)` either exists in SqliteStore or is straightforward to add | Open Questions | Cascade implementation blocked until confirmed |
| A4 | Ephemeral product bug may already be protected by `github_issue_number IS NULL` guard in stale-cleanup | Ephemeral Products section | If protection is insufficient, D-08 needs stronger fix |
| A5 | Image display bug may already be fixed in Phase 20.1 | Image Pipeline Status | If still present, additional tasks needed |

---

## Sources

### Primary (HIGH confidence)
- `crates/app/src/live_client.rs` — Verified: run_sync_cycle, broken lines 914-921, detect_card_changes (1448), sync_card_issues (1340), sync_products (1118), sync_return_states
- `crates/service/src/sync/shopify_projection.rs` — Verified: project_for_order(), normalize_fulfillment_status(), normalize_tracking_state()
- `crates/integrations/src/shopify/http_client.rs` — Verified: parse_fulfillments_response(), parse_tracking_events_response(), ShopifyOrderFulfillmentClient impl
- `crates/integrations/src/shopify/graphql_client.rs` — Verified: reverse_fulfillment_tracking(), returns_for_order(), ReverseTrackingStatus
- `crates/integrations/src/shopify/order_fulfillment_client.rs` — Verified: Fulfillment, TrackingEvent structs (no line_item_product_ids)
- `crates/app/src/dashboard/assignment.rs` — Verified: PROMPT_FREE_STATES, try_assign_unit, state machine
- `crates/app/src/dashboard/pending_edit_flusher.rs` — Verified: flush_one(), edit_type dispatch, queue_unit_state_edit()
- `crates/service/src/db/sqlite.rs` — Verified: SqliteStore structure, CardRow, ProductUnitRow
- `crates/service/src/db/migrations/V007__serial_state_rename.sql` — Verified: "Created" → "Available" already done
- `crates/app/src/main.rs` (lines 4037-4176) — Verified: lookup modal product add paths, existing background GH Issue creation
- `code_tips/SQLITE_TIPS.md` — Verified: INSERT OR REPLACE pitfall, DELETE+re-INSERT pitfall
- `crates/bugsweeper/GUIDE.md` — Verified: full endpoint reference, SQL query endpoint, verification patterns

### Secondary (MEDIUM confidence)
- Phase 20.1 CONTEXT.md patterns and decisions — inferred from commit messages and state.md

---

## Metadata

**Confidence breakdown:**
- Sync pipeline structure: HIGH — read full source
- Fulfillment struct extension: MEDIUM — Shopify API field names assumed from patterns
- Tracking event string values: MEDIUM — based on existing code, not verified against live API
- Image bug status: LOW — requires runtime verification (D-10)
- Ephemeral product bug root cause: MEDIUM — stale-cleanup guard reviewed but race condition timing not fully traced

**Research date:** 2026-04-13
**Valid until:** 2026-05-13 (stable codebase)
