# Phase 12.1.1: Shopify-First Card Source Pipeline — Research

**Researched:** 2026-03-21
**Domain:** Rust data pipeline refactor — Shopify REST API, GitHub Project V2 GraphQL mutations, Slint UI extensions
**Confidence:** HIGH (codebase) / MEDIUM (Shopify tag filter) / HIGH (GH GraphQL mutation)

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- New Shopify API endpoint: `GET /admin/api/2024-01/orders.json?status=any&tag=wit-what&limit=250`
- Must support pagination (Link header / page_info cursor) for >250 orders
- Three sync modes: 5-minute auto-sync (since-based, `updated_at_min`), manual refresh (full re-fetch), startup/runtime sync (full re-fetch)
- One card per order (not per customer)
- Match Shopify customer ID from GH Project "Shopify Profile URL" field last path segment vs `order.customer.id`
- First match wins for duplicate customer IDs (log warning)
- Recipients without Shopify Profile URL are not considered for auto-matching
- Unassigned cards: same layout, name in italics + warning color, no order number
- Unassigned cards sort into same status groups as assigned cards — no separate section
- "Pick Recipient" in ellipsis menu with warning color text
- Modal title: "Pick Recipient for {Shopify Customer Name}"
- Modal search input fuzzy-filters; list shows only GH Project entries where Shopify Profile URL is empty/unset
- Selecting recipient: assign + close modal immediately
- "Create New Recipient" button at bottom of list
- Immediate GH Project write-back: update recipient's "Shopify Profile URL" field via GH Project mutation API
- Card updates locally immediately on assignment
- Create New Recipient: pre-fill name from Shopify customer first+last, pre-fill Shopify Profile URL
- Create new GH Project item via API; assign immediately

### Claude's Discretion

- GraphQL mutation shape for GH Project field updates
- Exact fuzzy search algorithm for recipient filtering
- Shopify pagination implementation (cursor-based vs page_info)
- How `run_sync_cycle` is refactored to be Shopify-first
- Error handling for failed GH Project write-backs (retry? toast?)
- Whether `orders_for_customer` is removed or kept as secondary enrichment

### Deferred Ideas (OUT OF SCOPE)

- Order number display on cards
- Batch assignment
- Customer merge handling
</user_constraints>

---

## Summary

This phase refactors the core data pipeline from GitHub-Project-first to Shopify-order-first. The current `run_sync_cycle` fetches GH Project recipients then joins zero Shopify data. The new pipeline reverses this: fetch all Shopify orders tagged "wit-what", then match each order's customer ID against GH Project recipients via their stored "Shopify Profile URL" field. Unmatched orders become "unassigned" cards with italic/warning-color name treatment and a "Pick Recipient" ellipsis menu action.

The codebase is structured with clean layer separation (integrations crate, service crate, app crate). The refactor is mostly additive: new `orders_by_tag()` method on `HttpShopifyClient`, new Shopify-first sync path in `run_sync_cycle`, new `unassigned` flag on card view model, a new recipient-picker modal reusing the existing `LookupModal` pattern, and GH Project write-back via `gh api graphql` mutation. The per-card card model (`DashboardCardViewModel`) and `CardData` Slint struct need minimal extension — only an `is-unassigned` bool and an `unassigned-customer-name` string are required.

**Primary recommendation:** Keep `run_sync_cycle` signature and replace its body. Add `orders_by_tag()` to the Shopify client trait. Add `shopify_profile_url` to `GithubMappedRecipient`. Add a new recipient-picker modal that reuses the LookupModal fullscreen-backdrop pattern.

---

## Standard Stack

### Core (existing — no new dependencies)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| ureq | existing | HTTP calls for Shopify REST API | Already in use for Shopify client |
| serde_json | existing | JSON parsing of order/customer objects | Already used throughout |
| gh CLI (`gh api graphql`) | existing | GH Project mutations | Already used for all GH Project queries |
| slint | existing | Recipient-picker modal UI | Existing modal pattern established |

### No new crate dependencies needed
The entire feature is implementable with existing crates. The GH Project write-back uses the same `gh api graphql` subprocess pattern already established in `gh_cli_client.rs`.

---

## Architecture Patterns

### Key Research Finding: `shopify_profile_url` is NOT currently in the codebase
The GH Project "Shopify Profile URL" field is referenced in decisions but does not yet appear in `GithubMappedRecipient`, `project_mapping.rs`, or any other source file. The planner must include a task to:
1. Add `shopify_profile_url: Option<String>` to `GithubMappedRecipient`
2. Parse it from the GH Project row field named "Shopify Profile URL" in `map_rows()`
3. Pass it through the sync pipeline for matching

### Recommended Project Structure (new files only)
```
crates/integrations/src/shopify/
├── http_client.rs          # ADD: orders_by_tag(), parse_tagged_orders_response()
├── order_fulfillment_client.rs  # ADD: orders_by_tag() to trait

crates/service/src/sync/
├── shopify_order_source.rs  # NEW: Shopify-first pipeline logic
│                            # extract_customer_id_from_profile_url()
│                            # match_orders_to_recipients()
│                            # build_unassigned_card_snapshot()

crates/app/src/
├── live_client.rs           # REFACTOR: run_sync_cycle() body
├── dashboard/view_model.rs  # ADD: is_unassigned, unassigned_shopify_customer_name fields
├── dashboard/projection.rs  # ADD: project_unassigned_snapshot()

crates/app/ui/
├── dashboard.slint          # ADD: is-unassigned to CardData, pick-recipient callback
├── card.slint               # ADD: is-unassigned bool, pick-recipient callback
├── recipient-picker.slint   # NEW: recipient picker modal (or extend lookup-modal.slint)
```

### Pattern 1: Shopify Orders by Tag — REST API Pagination
**What:** `GET /admin/api/2024-01/orders.json?status=any&tag=wit-what&limit=250` returns first page. Link header contains next page URL with `page_info` cursor.
**When to use:** Full re-fetch (manual refresh and startup)
**Pagination rule:** Subsequent requests use ONLY `page_info` and `limit` — no other filter params.

```rust
// Source: Shopify REST Admin API pagination docs
// https://shopify.dev/docs/api/admin-rest/usage/pagination
fn orders_by_tag(&self, tag: &str, since: Option<&str>) -> Vec<ShopifyOrderFull> {
    let mut url = format!(
        "{}/orders.json?status=any&tag={}&limit=250",
        self.base_url(), tag
    );
    if let Some(ts) = since {
        url.push_str(&format!("&updated_at_min={}", ts));
    }
    let mut all_orders = Vec::new();
    loop {
        let response = ureq::get(&url)
            .set("X-Shopify-Access-Token", &self.access_token)
            .call();
        let resp = match response { Ok(r) => r, Err(_) => break };

        // Extract next page URL from Link header before consuming body
        let next_url = parse_link_header_next(resp.header("link"));

        let json: serde_json::Value = resp.into_json().unwrap_or_default();
        all_orders.extend(parse_tagged_orders_response(&json));

        match next_url { Some(u) => url = u, None => break }
    }
    all_orders
}
```

**Link header parsing pattern:**
```rust
fn parse_link_header_next(header: Option<&str>) -> Option<String> {
    // Header format: <URL>; rel="next", <URL>; rel="previous"
    header?.split(',')
        .find(|part| part.contains("rel=\"next\""))
        .and_then(|part| {
            let start = part.find('<')? + 1;
            let end = part.find('>')?;
            Some(part[start..end].to_string())
        })
}
```

### Pattern 2: Customer ID Extraction from Shopify Profile URL
**What:** Parse the last numeric path segment from `https://admin.shopify.com/store/{slug}/customers/{id}`
**Confidence:** HIGH — confirmed that admin URL customer ID equals REST API `customer.id` (same numeric value, Shopify uses consistent simple IDs across both surfaces)

```rust
// Source: Shopify REST Admin API simple IDs documentation
// https://shopify.dev/docs/api/admin-rest/usage/simple-ids
fn extract_customer_id_from_shopify_profile_url(url: &str) -> Option<String> {
    url.trim_end_matches('/')
        .split('/')
        .next_back()
        .filter(|s| s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty())
        .map(|s| s.to_string())
}
```

### Pattern 3: GitHub Project Mutation via gh CLI
**What:** Update a text field on a GH Project item using `gh api graphql`
**When to use:** After recipient assignment, write Shopify Profile URL back to the GH Project row

```rust
// Source: GitHub Docs — Using the API to manage Projects
// https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects
let mutation = r#"
  mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) {
    updateProjectV2ItemFieldValue(input: {
      projectId: $projectId
      itemId: $itemId
      fieldId: $fieldId
      value: { text: $value }
    }) {
      projectV2Item { id }
    }
  }
"#;
// gh api graphql -f query=MUTATION -F projectId=... -F itemId=... -F fieldId=... -F value=...
```

**Field ID lookup query** (run once at startup or cache):
```graphql
query($projectId: ID!) {
  node(id: $projectId) {
    ... on ProjectV2 {
      fields(first: 20) {
        nodes {
          ... on ProjectV2FieldCommon { id name }
        }
      }
    }
  }
}
```

This query needs to be added to `GhCliProjectClient` as `fetch_field_id(project_id, field_name)` or the field ID can be discovered at runtime. The `gh_cli_client.rs` already uses this same `gh api graphql` pattern with arg-passing via `-F` flags.

### Pattern 4: New Sync Pipeline Shape
The current `run_sync_cycle` is GitHub-first (GH recipients → empty Shopify join). The new shape:

```
1. Fetch GH Project rows → build recipient map keyed by shopify_customer_id
2. Fetch Shopify orders (tagged "wit-what", all pages)
3. For each order:
   a. Match order.customer.id against recipient map
   b. If matched: build RecipientCardSnapshot (assigned card)
   c. If unmatched: build UnassignedCardSnapshot (unassigned card)
4. Merge assigned snapshots with existing repo state (fulfillments, tracking)
5. Emit all snapshots (assigned + unassigned) to UI callback
```

The existing `SyncUpdateCallback` type alias `Arc<dyn Fn(Vec<RecipientCardSnapshot>, i32) + Send + Sync>` can be extended with a second vec for unassigned, OR `RecipientCardSnapshot` can gain an `is_unassigned: bool` field. The second option is less disruptive to the callback signature.

### Pattern 5: Recipient Picker Modal
The existing `LookupModal` in `lookup-modal.slint` uses the full-overlay `Rectangle` backdrop pattern (not `PopupWindow`) for dimming support. The new recipient-picker modal should follow the same pattern. The picker is a distinct UX from the item lookup (different data, different title, different action), so a new `RecipientPickerModal` component is cleaner than extending `LookupModal`.

**Slint fuzzy search:** Implement in Rust via the existing `filter_cards_by_search` pattern (substring containment). For the recipient list, case-insensitive substring match on recipient name is sufficient — no external fuzzy library needed.

### Anti-Patterns to Avoid
- **Reusing `orders_for_customer` in the new pipeline:** The new flow is orders-first, not customer-first. `orders_for_customer` was designed for the old GH-first approach where you had a customer ID in hand. Keep it for secondary enrichment (fulfillment data) but don't use it as the primary fetch.
- **Storing unassigned cards in the Repository:** Unassigned cards have no `recipient_id` in the GH Project. They should not be stored in the `service::db::repository::Repository` (which expects a stable recipient_id). Keep unassigned cards in an in-memory `Vec` in `LiveClient`.
- **Blocking on GH write-back during sync:** The GH Project mutation for write-back should happen off the sync loop (triggered by user assignment, not by sync). Never block the 5-minute sync waiting for a write-back.
- **Removing `orders_for_customer` prematurely:** Keep the existing method until it's confirmed no longer needed — it may be useful for fetching fulfillment data for matched orders.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| HTTP pagination | Custom retry/loop logic | ureq + parse Link header (see Pattern 1) | Link header is the only correct mechanism |
| GH Project write-back | REST API client | `gh api graphql` subprocess (existing pattern) | Already established, auth handled by gh CLI |
| Fuzzy search | levenshtein/fuzzy lib | Case-insensitive substring match | Recipient list is small (<100 items), substring is sufficient and matches existing `filter_cards_by_search` |
| Shopify customer ID equivalence check | API lookup fallback | Direct string equality on last path segment | Admin URL numeric ID equals REST API `customer.id` — confirmed HIGH confidence |

**Key insight:** This phase is a data-flow refactor, not a new integration. Resist the temptation to add new external dependencies. Everything needed is either already in the codebase or expressible with ureq + gh CLI.

---

## Common Pitfalls

### Pitfall 1: `tag` filter is undocumented in Shopify REST API
**What goes wrong:** The `tag=` query parameter for `orders.json` works in practice but is NOT listed in the official Shopify REST API documentation. It could theoretically be removed without notice.
**Why it happens:** Shopify's REST API has many undocumented parameters that work due to underlying query infrastructure.
**How to avoid:** Use it as specified (the user locked this decision). The fallback if it stops working: post-filter on the already-fetched `orders.tags` array. The code already parses `tags` in `parse_orders_response`.
**Warning signs:** API returns all orders regardless of tag filter (then the client-side tag filter in `ShopifyOrder.tags` will catch it).
**Confidence:** MEDIUM — undocumented but community-confirmed functional.

### Pitfall 2: Pagination constraint — no filter params after first page
**What goes wrong:** On page 2+, if you re-add `status=any&tag=wit-what`, the API returns an error or ignores pagination.
**Why it happens:** Shopify REST pagination requires `page_info` to be the only parameter (besides `limit` and `fields`). The cursor encodes the query.
**How to avoid:** On first request, use full filter URL. On subsequent requests, use ONLY the full URL from the Link header (it already contains `page_info`). Do NOT reconstruct the URL.

### Pitfall 3: `shopify_profile_url` field not yet in GithubMappedRecipient
**What goes wrong:** The matching logic expects `GithubMappedRecipient.shopify_profile_url` but the field doesn't exist yet. `map_rows()` currently only reads `github_profile_url`, `discord_username`, `discord_user_id`.
**How to avoid:** Add `shopify_profile_url: Option<String>` to `GithubMappedRecipient` and parse it from the GH Project field named "Shopify Profile URL" in `map_rows()`. This is a required Wave 0 task.

### Pitfall 4: GH Project field ID must be discovered at runtime
**What goes wrong:** The mutation `updateProjectV2ItemFieldValue` requires a `fieldId` which is a GH node ID (e.g., `PVTF_abc123`), not just the field name string.
**Why it happens:** GH Project field IDs are opaque node IDs different from field names.
**How to avoid:** Add a `fetch_field_id(project_id, field_name)` method to `GhCliProjectClient` that runs the fields query (see Pattern 3) and returns the matching field's node ID. Cache the result in `LiveClient` after first fetch. The `gh api graphql` pattern for this is already established in the codebase.

### Pitfall 5: Unassigned cards need stable IDs for Slint model
**What goes wrong:** Slint's model system indexes cards by position. Unassigned cards have no `recipient_id`. If the card list re-orders between syncs, card indices become stale.
**How to avoid:** Use Shopify order ID as the "recipient_id" placeholder for unassigned cards (e.g., `"unassigned:order:{order_id}"`). This gives them a stable identity across sync cycles without polluting the Repository with non-recipient records.

### Pitfall 6: The existing `LiveClient.SyncUpdateCallback` passes only assigned-recipient snapshots
**What goes wrong:** The callback `Arc<dyn Fn(Vec<RecipientCardSnapshot>, i32) + Send + Sync>` has no slot for unassigned cards.
**How to avoid:** Add `is_unassigned: bool` and `unassigned_customer_name: Option<String>` to `RecipientCardSnapshot` (minimal additive change). The planner should treat this as a required schema extension, not an optional enhancement.

### Pitfall 7: Write-back requires `project` OAuth scope, not just `read:project`
**What goes wrong:** The `gh auth login` token may only have `read:project` scope, which blocks mutations.
**How to avoid:** The write-back uses `gh api graphql` which inherits the `gh` CLI's authenticated token. The gh CLI's token typically has `project` scope if the user has used gh for project operations. Document in code that write-back will fail silently if scope is missing, and surface this via toast error.

---

## Code Examples

### Shopify Tagged Orders Response Extended Parse

```rust
// Source: crates/integrations/src/shopify/http_client.rs — extending existing parse_orders_response
// ShopifyOrderFull needs first_name, last_name from customer object
pub fn parse_tagged_orders_response(json: &serde_json::Value) -> Vec<ShopifyOrderFull> {
    let orders = match json.get("orders").and_then(|v| v.as_array()) {
        Some(arr) => arr,
        None => return Vec::new(),
    };
    orders.iter().filter_map(|order| {
        let order_id = order.get("id")?.as_u64()?.to_string();
        let customer = order.get("customer")?;
        let customer_id = customer.get("id")?.as_u64()?.to_string();
        let first_name = customer.get("first_name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let last_name = customer.get("last_name")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let tags_str = order.get("tags").and_then(|v| v.as_str()).unwrap_or("");
        let tags: Vec<String> = tags_str.split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();
        Some(ShopifyOrderFull { order_id, customer_id, first_name, last_name, tags })
    }).collect()
}
```

### GH Project Write-Back via gh CLI (run_mutation)

```rust
// Source: pattern derived from existing GhCliProjectClient::fetch_rows()
// in crates/integrations/src/github/gh_cli_client.rs
fn update_project_field_text(
    &self,
    project_id: &str,
    item_id: &str,
    field_id: &str,
    value: &str,
) -> Result<(), GithubProjectClientError> {
    let mutation = r#"mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$value:String!){
      updateProjectV2ItemFieldValue(input:{
        projectId:$projectId itemId:$itemId fieldId:$fieldId value:{text:$value}
      }){ projectV2Item { id } }
    }"#;
    let output = Command::new(&self.gh_path)
        .args([
            "api", "graphql",
            "-f", &format!("query={}", mutation),
            "-F", &format!("projectId={}", project_id),
            "-F", &format!("itemId={}", item_id),
            "-F", &format!("fieldId={}", field_id),
            "-F", &format!("value={}", value),
        ])
        .output()
        .map_err(|e| GithubProjectClientError::Transport(e.to_string()))?;
    if !output.status.success() {
        return Err(GithubProjectClientError::Transport(
            String::from_utf8_lossy(&output.stderr).to_string()
        ));
    }
    Ok(())
}
```

### Add shopify_profile_url to GithubMappedRecipient (project_mapping.rs)

```rust
// Source: crates/integrations/src/github/project_mapping.rs — additive change
pub struct GithubMappedRecipient {
    pub github_item_id: String,
    pub github_profile_url: String,
    pub shopify_profile_url: Option<String>,  // NEW
    pub discord_username: Option<String>,
    pub discord_user_id: Option<String>,
    pub recipient_key: String,
}

// In map_rows():
mapped.push(GithubMappedRecipient {
    github_item_id: row.item_id.clone(),
    github_profile_url: profile_url.clone(),
    shopify_profile_url: row.fields.get("Shopify Profile URL").cloned(),  // NEW
    discord_username: row.fields.get("discord_username").cloned(),
    discord_user_id: row.fields.get("discord_user_id").cloned(),
    recipient_key: username,
});
```

### Slint CardData extension (dashboard.slint)

```slint
// Source: crates/app/ui/dashboard.slint — additive fields on CardData struct
struct CardData {
    // ... existing fields ...
    is-unassigned: bool,                    // NEW
    unassigned-customer-name: string,       // NEW — Shopify first+last name
}
```

---

## State of the Art

| Old Approach | Current Approach | Impact |
|--------------|------------------|--------|
| GH Project-first sync (recipients as source of truth) | Shopify orders as source of truth (this phase) | Adds orders without GH Project entries as unassigned cards |
| Customer matching via Shopify API lookup | Customer matching via stored "Shopify Profile URL" field | Eliminates per-recipient Shopify customer lookup during sync |
| No unassigned cards concept | Unassigned cards for orders without a matched recipient | New card variant requiring UI treatment |
| No GH Project write-back | Write-back via `updateProjectV2ItemFieldValue` mutation | Enables assignment to persist across sessions |

---

## Open Questions

1. **What is the exact GH Project field name for "Shopify Profile URL"?**
   - What we know: Context.md refers to it as "Shopify Profile URL" (the GH Project column heading).
   - What's unclear: Whether the `GithubProjectRow.fields` HashMap key matches exactly — it depends on the actual column name in the user's GH Project. Case sensitivity matters.
   - Recommendation: Use `row.fields.get("Shopify Profile URL")` as the key. Document in code that it must match the GH Project column name exactly. The GH Project query already fetches `field.name` verbatim.

2. **Should `orders_for_customer` be removed from the trait?**
   - What we know: The new pipeline doesn't call it as the primary fetch. But it may be useful for fetching fulfillment/tracking data for matched orders.
   - What's unclear: Whether fulfillment enrichment (tracking status) still needs per-order lookups.
   - Recommendation (Claude's discretion): Keep `orders_for_customer` for now; add `orders_by_tag()` as a new trait method. Remove `orders_for_customer` in a future cleanup phase.

3. **Error handling for failed GH write-back**
   - What we know: The write-back is a critical UX step — if it fails, the assignment doesn't persist.
   - Recommendation (Claude's discretion): Show a toast error ("Failed to save assignment. Try again.") with no auto-retry. The next sync will re-derive the card as unassigned, making the failure visible.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in test (`#[test]`) + cargo test |
| Config file | none (workspace Cargo.toml) |
| Quick run command | `cargo test -p integrations` or `cargo test -p app` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements → Test Map
| Behavior | Test Type | Automated Command |
|----------|-----------|-------------------|
| `orders_by_tag()` fetches orders and paginates | unit | `cargo test -p integrations parse_tagged_orders` |
| `extract_customer_id_from_shopify_profile_url` parses URL correctly | unit | `cargo test -p service extract_customer_id` |
| Customer ID from admin URL matches REST API ID | unit (via parse) | confirmed HIGH confidence, no live test needed |
| `match_orders_to_recipients` assigns matched, produces unassigned | unit | `cargo test -p service match_orders` |
| `update_project_field_text` formats gh CLI args correctly | unit (parse output) | `cargo test -p integrations update_project_field` |
| Link header next-URL extraction | unit | `cargo test -p integrations parse_link_header` |
| Unassigned card rendered with italic/warning treatment | manual | visual inspection |
| Pick Recipient modal opens with correct title | manual | visual inspection |
| Assignment writes back to GH Project | integration (requires live gh auth) | manual |

### Wave 0 Gaps
- [ ] `crates/integrations/src/shopify/http_client.rs` — no `orders_by_tag()` method or test yet
- [ ] `crates/service/src/sync/shopify_order_source.rs` — new file, no tests yet
- [ ] `crates/integrations/src/github/project_mapping.rs` — `shopify_profile_url` field missing
- [ ] `crates/app/ui/recipient-picker.slint` — new modal component

---

## Sources

### Primary (HIGH confidence)
- Codebase: `crates/app/src/live_client.rs` — `run_sync_cycle()` at line 248, full pipeline examined
- Codebase: `crates/integrations/src/shopify/http_client.rs` — existing `HttpShopifyClient` structure
- Codebase: `crates/integrations/src/github/gh_cli_client.rs` — existing `gh api graphql` pattern
- Codebase: `crates/integrations/src/github/project_mapping.rs` — `GithubMappedRecipient` (confirmed `shopify_profile_url` absent)
- Codebase: `crates/app/ui/dashboard.slint` — `CardData` struct, callback inventory
- [GitHub Docs — Using the API to manage Projects](https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects) — `updateProjectV2ItemFieldValue` mutation shape and field ID query
- [Shopify REST Admin API — Pagination](https://shopify.dev/docs/api/admin-rest/usage/pagination) — Link header format, page_info constraints
- [Shopify REST Admin API — Simple IDs](https://shopify.dev/docs/api/admin-rest/usage/simple-ids) — admin URL numeric ID equals REST API `customer.id`

### Secondary (MEDIUM confidence)
- [Shopify Community — REST Orders API tag filter](https://community.shopify.com/c/shopify-apis-and-sdks/rest-orders-api-filtering-orders-via-tags-tag/td-p/1539159) — `tag=` parameter works but is undocumented; confirmed by community, no official endorsement

### Tertiary (LOW confidence)
- None

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all existing, no new deps
- Architecture patterns: HIGH — derived from codebase audit with confirmed integration points
- Shopify tag filter: MEDIUM — undocumented API parameter, community-confirmed functional
- Admin URL customer ID == REST API customer.id: HIGH — confirmed via official Shopify simple-ID documentation
- GH Project mutation shape: HIGH — confirmed via official GitHub Docs
- Pitfalls: HIGH — derived from direct code reading and pagination documentation

**Research date:** 2026-03-21
**Valid until:** 2026-06-21 (Shopify REST tag parameter is stable risk; GH GraphQL mutations are stable)
