---
phase: 20.2-sync-integrity-and-shipment-state-fixes
plan: 02
subsystem: sync/shipment-state/cascade
tags: [shopify, fulfillment, serial-units, cascade, tdd, state-machine]
dependency_graph:
  requires: [20.2-01]
  provides: [cascade_shipment_state_to_units, Packing_state_in_state_machine]
  affects: [live_client.run_sync_cycle, dashboard/assignment.rs]
tech_stack:
  added: []
  patterns: [TDD red-green, product-aware cascade, HashSet matching, change-detection guard]
key_files:
  created: []
  modified:
    - crates/app/src/dashboard/assignment.rs
    - crates/app/src/live_client.rs
decisions:
  - "read_units_by_card (not read_serial_instances_for_card) — method already existed in sqlite.rs, no new method needed"
  - "read_product_by_id (not read_product) — correct method name in sqlite.rs"
  - "cascade placed at Step 4c before Step 5 (product sync) — does not require gh_issues_client to be present"
  - "Packing not added to PROMPT_FREE_STATES — requires reassignment prompt per D-05"
  - "unassigned cards also tracked in card_fulfillment_products but cascade only fires on matched (persisted) cards"
metrics:
  duration_minutes: 15
  completed_date: "2026-04-13"
  tasks_completed: 2
  files_changed: 2
---

# Phase 20.2 Plan 02: Product-Aware Serial Unit Cascade Summary

**One-liner:** Product-aware serial unit state cascade from card shipment status via Shopify fulfillment product ID matching, with Packing added to the state machine per D-05.

## What Was Built

### Task 1: cascade_shipment_state_to_units + state machine update (TDD)

**cascade_shipment_state_to_units** (`assignment.rs`):
- New pub function: `(store, card_id, new_card_status, fulfillment_shopify_product_ids: &[String])`
- State mapping per D-07: Preparing returns early (no cascade); Packing/In Transit/Delivered/Return In Transit/Returned cascade to matching units
- Uses `store.read_units_by_card(card_id)` to fetch assigned units
- Per D-06: units whose product's `shopify_product_url` numeric ID appears in `fulfillment_shopify_product_ids` get cascaded; units with no Shopify product URL always cascade
- Idempotent: skips units already at the target state
- Queues GH Issue write-back via `pending_edit_flusher::queue_unit_state_edit` for each updated unit

**get_shopify_product_id_for_unit** (`assignment.rs`):
- Private helper: extracts numeric Shopify product ID from the product's `shopify_product_url`
- URL format: `https://{shop}.myshopify.com/admin/products/{numeric_id}` — takes last path segment via `rsplit('/')`
- Returns `None` if product has no Shopify URL (triggers always-cascade behavior)

**State machine update (D-05):**
- "Packing" is NOT added to `PROMPT_FREE_STATES` — a unit in Packing requires a reassignment prompt (same as Assigned/In Transit)
- Existing `try_assign_unit` logic correctly handles Packing as a prompt-requiring state since it falls through the `!is_prompt_free` branch

**7 TDD tests written and passing:**
- `cascade_preparing_does_not_change_unit_states`
- `cascade_packing_changes_matching_unit_to_packing`
- `cascade_in_transit_changes_matching_unit`
- `cascade_skips_unit_whose_product_not_in_fulfillment`
- `cascade_applies_to_unit_with_no_shopify_product_association`
- `cascade_is_idempotent_when_unit_already_at_target_state`
- `packing_is_valid_state_in_state_machine_needs_reassignment`
- `assign_packing_unit_to_different_card_needs_reassignment`

### Task 2: Wire cascade into run_sync_cycle (live_client.rs)

**card_fulfillment_products HashMap:**
- Declared before the order loop: `HashMap<String, Vec<String>>` mapping card_id → fulfillment product IDs
- `fulfillment_product_ids` (was `_fulfillment_product_ids`) stored for both matched and unassigned cards after their respective snapshot pushes

**Step 4c cascade block (after card upserts, before Step 5):**
- Reads post-upsert cards via `store.read_all_cards()`
- For each card: compares `old_card.shipment_status` vs `new_card.shipment_status` — cascade fires only on actual change
- Passes `card_fulfillment_products.get(&card_id).cloned().unwrap_or_default()` to cascade function
- Placed before the `if let Some(issues_client)` block — cascade does not require GH Issues client

## Verification Results

- `cargo test -p app assignment` — 17 passed (includes 8 new cascade/state tests)
- `cargo test --lib -p app` — 196 passed
- `cargo check` — clean (1 pre-existing unused variable warning in `remove_unit_from_card`, unrelated to this plan)

## Assumed Annotation Resolutions

**A3 (read_serial_instances_for_card):** Resolved — `read_units_by_card(card_id)` already exists in sqlite.rs at line 1014. No new method needed.

**A2 (Fulfillment line_item_product_ids):** Confirmed from Plan 01 SUMMARY — field correctly implemented, Shopify integer IDs serialized to string via `.to_string()`.

## Deviations from Plan

### Auto-fixed Issues

None — plan executed exactly as written, with method name corrections based on actual code reading (A3 annotation resolution).

### Naming corrections (not deviations)

- Plan template used `read_serial_instances_for_card` — actual method is `read_units_by_card` (A3 annotation flagged this for resolution)
- Plan template used `store.read_product(product_id)` — actual method is `read_product_by_id(product_id)` (simple name difference, corrected during reading)
- Plan template placed cascade inside `if let Some(issues_client)` block — moved to Step 4c outside that block since cascade doesn't need GH Issues client (correctness improvement)

## Known Stubs

None — cascade is fully wired. Packing state machine update is complete.

## Threat Flags

None — no new network endpoints or trust boundaries introduced. Fulfillment product ID matching uses Shopify-authenticated data (T-20.2-05 accepted). GH write-back uses validated PendingEditFlusher path (T-20.2-07 mitigated).

## Self-Check: PASSED

- `crates/app/src/dashboard/assignment.rs` contains `pub fn cascade_shipment_state_to_units` — FOUND
- `crates/app/src/dashboard/assignment.rs` contains `fn get_shopify_product_id_for_unit` — FOUND
- `crates/app/src/live_client.rs` contains `card_fulfillment_products` — FOUND
- `crates/app/src/live_client.rs` contains `cascade_shipment_state_to_units` call — FOUND
- Commit e71c395 (Task 1) — FOUND
- Commit c77d4bd (Task 2) — FOUND
