# Phase 9: Item & Note CRUD Wiring - Research

**Researched:** 2026-03-12
**Domain:** Rust/Slint desktop app — CRUD dispatch wiring, pending-edit queue, catalog search modal, optimistic UI update
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- **Post-save card refresh:** Optimistic update — card UI updates immediately from local data after edit. Failed edits queue locally in a persistent file (survives restart). Retry: piggyback on 5-min auto-poll + flush queue on app start. No failure notification — silent queue.
- **Sync status indicator:** Global sync indicator shows pending edit count (e.g., "2 edits pending"). Decrements as edits sync. Disappears when queue is empty. No escalation threshold.
- **Error feedback:** Silent queue for network failures. Client-side validation blocks invalid edits before queue entry. Validation errors show inline on the field (red border + short message). Validation errors do NOT enter the queue.
- **Item add flow:** Full "Shipment Product Lookup" catalog search modal wired in this phase. Centered floating modal. Rest of app dimmed behind semi-transparent backdrop. Target card shows a white square placeholder with slow breathing opacity animation in the item slot during search. Results list with thumbnails (image_hint fallback to generic icon). "Create New" always last in results list. New item creation form: display name + optional Shopify product page URL. If Shopify URL supplied, app auto-fetches product image via Shopify Admin API.
- **Item removal:** Unassign item from package, preserve catalog record (soft-delete via `deactivate_item`). Removal confirmation required before dispatch.
- **Note editing:** Explicit Save/Cancel (not auto-save on blur). Multi-line plain text. Same optimistic update + queue pattern as item edits.

### Claude's Discretion
- Pending-edit file format and location (JSON file in app data directory)
- Exact breathing animation timing/easing for the placeholder square
- Shipment Product Lookup modal dimensions and search debounce timing
- Inline validation message wording and styling
- Global sync indicator placement and visual treatment

### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| ITEM-01 | User can add, edit, and remove WITwhat-owned "items in possession." | EditCommand/EditDispatcher fully defined; service API layer (add_item_to_package, remove_item_from_package, rename_item) exists; stubs at main.rs:1007-1015 are the exact replacement targets. |
| ITEM-03 | User can add/edit latest note for recipient/package context. | save_package_note in api/items.rs exists; on_card_save_note stub at main.rs:1007 is the replacement target; note editing UI (Save/Cancel) already in card.slint. |
</phase_requirements>

---

## Summary

Phase 9 replaces three `println!` stub handlers in `main.rs` (lines 1007–1015) with real dispatch calls that persist through the `EditDispatcher` → `DashboardDataClient` → service API pipeline. The full infrastructure is already built: `EditCommand`, `EditDispatcher`, and all four service-layer functions (`add_item_to_package`, `remove_item_from_package`, `rename_item`, `save_package_note`) exist and have test coverage. The gap is purely wiring.

Beyond the core wiring, this phase adds three new concerns: (1) a pending-edit queue that survives app restart for offline resilience, (2) a global sync-status indicator on the dashboard, and (3) the "Shipment Product Lookup" modal with catalog search, thumbnail results, "Create New" inline form, and optional Shopify image auto-fetch.

The codebase uses seed data (`NoopClient`) rather than a live backend, so "persistence" for this phase means persisting to the in-process `Repository` (already wired via service API) and writing the pending-edit queue file when the service call fails.

**Primary recommendation:** Wire callbacks with the established archive-callback pattern (`weak + Rc<RefCell<...>>`), apply optimistic updates to `all_cards_ref`, persist via `EditDispatcher`, and queue failures to a JSON file in `dirs::data_local_dir()`.

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| slint | 1.x | UI framework — modal, animation, property binding | Already in use; all UI changes must be in Slint |
| serde + serde_json | — | Serialize pending-edit queue to JSON file | Established Rust JSON pattern; JSON is human-readable for debugging |
| dirs | — | Locate platform-appropriate app data directory | Windows: `%APPDATA%\wit-what\pending_edits.json` |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| reqwest (or ureq) | — | Shopify Admin API image fetch for "Create New" flow | Only when user supplies a Shopify product URL |
| uuid | already used in items.rs | Generate item_id for new items | Already in service layer |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| serde_json file | SQLite via rusqlite | Overkill for a small edit queue; JSON file is simpler and matches project pattern |
| reqwest | ureq | ureq is sync-only (simpler for this context); reqwest has async; either works since this phase has no async runtime requirement |

**Installation (if needed):**
```bash
# In crates/app/Cargo.toml
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
# reqwest or ureq only if Shopify image fetch is implemented in this phase
```

---

## Architecture Patterns

### Recommended Module Layout
```
crates/app/src/
├── main.rs                    # Replace 3 println! stubs; add modal open logic
├── dashboard/
│   ├── actions.rs             # EditCommand, EditDispatcher (no changes needed)
│   ├── mod.rs                 # Export PendingEditQueue
│   └── edit_queue.rs          # NEW: PendingEditQueue struct + file I/O
crates/app/ui/
├── card.slint                 # Add: item placeholder square, validation state props
├── dashboard.slint            # Add: sync-status indicator; modal overlay callback
└── lookup-modal.slint         # NEW: Shipment Product Lookup modal component
```

### Pattern 1: Callback Wiring (established archive pattern)
**What:** Each callback captures `weak` window handle + `Rc<RefCell<...>>` state. Never borrow `all_cards_ref` and `runtime` simultaneously without careful ordering.
**When to use:** All three new real handlers (`on_card_save_note`, `on_card_add_item`, `on_card_remove_item`).

```rust
// Source: established pattern in main.rs (archive callbacks, lines 777-837)
{
    let weak = window.as_weak();
    let cards = all_cards_ref.clone();
    let queue = pending_queue.clone();
    window.on_card_save_note(move |idx, note| {
        if let Some(w) = weak.upgrade() {
            // 1. Read card data from Slint filtered model (NOT all_cards_ref)
            let (recipient_name, package_id) = match w.get_cards().row_data(idx as usize) {
                Some(cd) => (cd.recipient_name.to_string(), cd.package_id.to_string()),
                None => return,
            };
            // 2. Client-side validation
            let note_str = note.to_string();
            // (empty note is valid — clearing a note is allowed)
            // 3. Optimistic update to all_cards_ref
            {
                let mut cards_mut = cards.borrow_mut();
                for card in cards_mut.iter_mut() {
                    if card.recipient_name.to_string() == recipient_name {
                        card.note_preview = note_str.clone().into();
                    }
                }
            }
            // 4. Dispatch via EditDispatcher
            let cmd = EditCommand::SaveNote {
                recipient_id: recipient_name.clone(),
                package_id: package_id.clone(),
                note: note_str.clone(),
            };
            let client = NoopClient; // will become real client later
            let receipt = EditDispatcher::new(&client).dispatch(cmd);
            // 5. On failure: enqueue
            if !receipt.succeeded {
                queue.borrow_mut().push(PendingEdit::SaveNote { package_id, note: note_str });
                queue.borrow().flush_to_disk();
            }
            // 6. Re-apply filters so card grid refreshes
            // (runtime borrow must not overlap with cards borrow)
        }
    });
}
```

### Pattern 2: Pending Edit Queue
**What:** `PendingEditQueue` wraps `Vec<PendingEdit>` and serializes to a JSON file on every mutation.
**When to use:** On any `EditReceipt { succeeded: false }`. Flush on app start.

```rust
// Source: project design decision (Claude's discretion for format/location)
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PendingEdit {
    SaveNote { package_id: String, note: String },
    AddItem { package_id: String, display_name: String, image_hint: Option<String>, shopify_product_id: Option<String> },
    RemoveItem { item_id: String },
    RenameItem { item_id: String, new_display_name: String },
}

pub struct PendingEditQueue {
    edits: Vec<PendingEdit>,
    file_path: std::path::PathBuf,
}

impl PendingEditQueue {
    pub fn load_or_create(file_path: std::path::PathBuf) -> Self { ... }
    pub fn push(&mut self, edit: PendingEdit) { self.edits.push(edit); self.flush_to_disk(); }
    pub fn flush_to_disk(&self) { /* serde_json::to_writer */ }
    pub fn len(&self) -> usize { self.edits.len() }
    pub fn drain_all(&mut self) -> Vec<PendingEdit> { self.edits.drain(..).collect() }
}
```

Queue file location: `dirs::data_local_dir().unwrap_or_default().join("wit-what").join("pending_edits.json")`

### Pattern 3: Slint Looping Animation (breathing placeholder)
**What:** Slint `animate` block can loop using `iteration-count: -1` for infinite loops. Alternate direction with `direction: alternate`.
**When to use:** The white placeholder square shown in the item slot while catalog search is open.

```slint
// Source: Slint 1.x animate property
property <float> breath-opacity: 0.4;
states [
    searching when root.searching: {
        breath-opacity: 0.9;
        in {
            animate breath-opacity {
                duration: 1200ms;
                easing: ease-in-out;
                iteration-count: -1;
                direction: alternate;
            }
        }
    }
]
// Rectangle using breath-opacity for the placeholder square
```

Alternatively, drive from Rust via `slint::Timer::repeating` toggling a Slint property between two opacity values (simpler, avoids Slint animate loop complexity).

### Pattern 4: PopupWindow as Modal Overlay (established pattern)
**What:** Slint `PopupWindow` with `close-policy: no-auto-close` and a semi-transparent full-window backdrop rectangle, forced centered.
**When to use:** "Shipment Product Lookup" modal.

```slint
// Source: existing summary-popup pattern in card.slint
lookup-modal := PopupWindow {
    close-policy: no-auto-close;
    x: (parent.width - 480px) / 2;
    y: (parent.height - 520px) / 2;
    width: 480px;
    // Full-window dimming backdrop — declared FIRST so cards render above it? No:
    // PopupWindow already overlays. Use a backdrop Rectangle inside PopupWindow at negative offset.
    ...
    init => { modal-focus.focus(); }
}
```

**Backdrop gotcha:** `PopupWindow` in Slint renders above everything but does not automatically dim the content behind it. To dim the app, add a full-window semi-transparent `Rectangle` as a sibling of the modal trigger, toggled visible when modal opens. OR position a backdrop inside the `PopupWindow` extending to cover the parent window (negative x/y offsets with large width/height). The dashboard already sets `width: 1200px; height: 760px` — hardcoded backdrop dimensions work.

### Pattern 5: Index-to-recipient mapping
**What:** Callbacks receive a filtered Slint model index (`idx`). Must look up the card via `w.get_cards().row_data(idx as usize)` — NOT `all_cards_ref[idx]`. This is the same pattern established for archive callbacks.

```rust
// Source: established pattern — main.rs archive callbacks (lines 787, 818, 848)
let card_data = match w.get_cards().row_data(idx as usize) {
    Some(cd) => cd,
    None => return,
};
let recipient_name = card_data.recipient_name.to_string();
```

**Implication:** The `CardData` Slint struct needs `package_id` and `item_id` fields so callbacks can identify which package/item to mutate. Currently `CardData` in `dashboard.slint` does NOT have these fields — they must be added.

### Anti-Patterns to Avoid
- **Borrowing `all_cards_ref` and `runtime` in the same scope:** Causes double-borrow panics at runtime. Establish separate borrow scopes.
- **Using `all_cards_ref[idx]` for filtered index:** The Slint model is filtered; index may differ from `all_cards_ref` index. Always use `w.get_cards().row_data(idx as usize)`.
- **Putting validation failures in the pending queue:** Validation failures are inline UI errors only — they never enter the queue.
- **Looping Slint animate with `iteration-count: -1` inside `if` blocks:** Slint conditionally-rendered elements lose animation state when hidden/shown. Use a persistently-rendered element with opacity toggle instead.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON serialization of pending-edit queue | Custom text format, manual parsing | serde + serde_json | Type-safe, handles all edge cases, already Rust ecosystem standard |
| Platform data directory path | `std::env::var("APPDATA")` | `dirs` crate | Handles all Windows variants correctly; forward-compatible |
| UUID generation for new item IDs | Custom ID scheme | `uuid::Uuid::new_v4()` | Already used in `crates/service/src/api/items.rs` line 20 |
| Shopify product image fetch | Manual HTTP parsing | reqwest or ureq | HTTP client handles redirects, TLS, error cases |
| Optimistic card update | Full re-projection from snapshots | Direct mutation of `all_cards_ref` + `apply_filters` | Re-projection requires a live backend; seed data has no round-trip |

**Key insight:** The service layer already handles every mutation operation with proper version-check optimistic locking. The app layer's job is to call it, handle the `EditReceipt`, and manage optimistic UI separately from the underlying data contract.

---

## Common Pitfalls

### Pitfall 1: CardData lacks package_id / item_id fields
**What goes wrong:** `on_card_save_note` and `on_card_add_item` receive only a Slint model index. The callback needs `package_id` to call `save_package_note` / `add_item_to_package`. Currently `CardData` in `dashboard.slint` has no such field.
**Why it happens:** Phase 4 stubs were written before real dispatch was needed.
**How to avoid:** Add `package-id: string` and `active-item-ids: string` (comma-separated or first item) to `CardData` struct in `dashboard.slint`, and populate them from `DashboardCardViewModel` → `seed_cards()`.
**Warning signs:** Compiler error "no field `package_id` on type `CardData`" in Rust callback code.

### Pitfall 2: Double-borrow panic on RefCell
**What goes wrong:** Slint callbacks that access both `all_cards_ref.borrow_mut()` and `runtime.borrow()` in overlapping scopes cause panic.
**Why it happens:** Slint callbacks are closures that execute on the main thread; if a closure holds a borrow and triggers another callback that also borrows, RefCell panics.
**How to avoid:** Complete each borrow in its own block scope before the next. Established archive callbacks (lines 780-808) demonstrate the safe pattern.
**Warning signs:** Runtime panic `already borrowed: BorrowMutError` during UI interaction.

### Pitfall 3: Slint PopupWindow backdrop — no automatic dimming
**What goes wrong:** Shipment Product Lookup opens but the rest of the app is still fully interactive and visually undimmed.
**Why it happens:** Slint `PopupWindow` is a floating overlay but does not block input on the parent window or dim it visually.
**How to avoid:** Add a full-window semi-transparent `Rectangle` (e.g., `background: #00000066`) as a direct child of the root `Rectangle` in `dashboard.slint`, toggled visible by an `in property <bool> lookup-modal-open`. Place it in z-order before the modal `PopupWindow` declaration but after the card grid (last declaration wins in Slint z-order).
**Warning signs:** User can still click cards while modal is "open."

### Pitfall 4: Breathing animation in conditionally-rendered Slint elements
**What goes wrong:** The item placeholder square with breathing animation stops animating or animates incorrectly when wrapped in a Slint `if` condition.
**Why it happens:** Slint destroys and recreates elements inside `if` blocks; looping animations reset on creation.
**How to avoid:** Keep the placeholder square always in the tree; control visibility via `opacity: 0.0/1.0` and `visible: false` (which removes it from layout but preserves state) rather than `if`. Alternatively, drive the animation from a Rust-side `slint::Timer::repeating` that toggles an `in property <float> placeholder-opacity`.
**Warning signs:** The breathing animation only runs once or has a visible restart stutter.

### Pitfall 5: `on_card_add_item` callback signature only receives `int` (index)
**What goes wrong:** The existing Slint callback `callback card-add-item(int)` passes only the card index. The "Shipment Product Lookup" flow requires opening a modal that stays open while the user searches, and then a second action (product selected) triggers the actual `AddItem` dispatch. This is a two-step flow, not a single callback.
**Why it happens:** The original stub was one-dimensional. The real flow requires modal state and a separate "item selected" callback.
**How to avoid:** Add a new Slint callback `callback card-add-item-confirm(int, string, string, string)` (card-index, display_name, image_hint, shopify_product_id) OR manage modal state in Rust with the item selection dispatched from the modal's "confirm" button.
**Warning signs:** Trying to do the full lookup flow inside the existing `on_card_add_item` closure.

### Pitfall 6: `NoopClient` — no actual persistence
**What goes wrong:** `EditDispatcher` is wired but calls `NoopClient.add_item()` which returns `Ok(String::new())` — edits appear to succeed but nothing is stored.
**Why it happens:** The live service client is not yet wired (that is a later phase). The `NoopClient` is the only `DashboardDataClient` impl in `main.rs`.
**How to avoid:** Document explicitly that ITEM-01/ITEM-03 satisfaction requires the in-process `Repository` to be reachable from the app's `DashboardDataClient` impl. A `LocalServiceClient` wrapping a shared `Rc<RefCell<Repository>>` should be created in this phase to make persistence real within the process. The queue file handles cross-restart resilience.
**Warning signs:** Items appear to be added in the UI but disappear on restart — this is expected until `LocalServiceClient` wraps a persisted repository.

---

## Code Examples

### Save Note — Full Callback (verified pattern)
```rust
// Source: codebase — actions.rs dispatch + established archive callback pattern
{
    let weak = window.as_weak();
    let cards = all_cards_ref.clone();
    let queue = pending_queue.clone();
    window.on_card_save_note(move |idx, note| {
        let Some(w) = weak.upgrade() else { return };
        let Some(card_data) = w.get_cards().row_data(idx as usize) else { return };
        let note_str = note.to_string();
        let recipient_name = card_data.recipient_name.to_string();
        let package_id = card_data.package_id.to_string();
        // Optimistic update
        {
            let mut cards_mut = cards.borrow_mut();
            for c in cards_mut.iter_mut() {
                if c.recipient_name.to_string() == recipient_name {
                    c.note_preview = note_str.clone().into();
                }
            }
        }
        // Persist
        let cmd = EditCommand::SaveNote {
            recipient_id: recipient_name.clone(),
            package_id: package_id.clone(),
            note: note_str.clone(),
        };
        let client = /* LocalServiceClient */;
        let receipt = EditDispatcher::new(&client).dispatch(cmd);
        if !receipt.succeeded {
            queue.borrow_mut().push(PendingEdit::SaveNote { package_id, note: note_str });
        }
        // re-apply filters omitted for brevity — call apply_filters(&w, &cards.borrow(), &rt.borrow())
    });
}
```

### PendingEditQueue — Minimal Implementation
```rust
// Source: project design (Claude's discretion)
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PendingEdit {
    SaveNote { package_id: String, note: String },
    AddItem {
        package_id: String,
        display_name: String,
        image_hint: Option<String>,
        shopify_product_id: Option<String>,
    },
    RemoveItem { item_id: String },
    RenameItem { item_id: String, new_display_name: String },
}

pub struct PendingEditQueue {
    edits: Vec<PendingEdit>,
    path: PathBuf,
}

impl PendingEditQueue {
    pub fn load_or_create(path: PathBuf) -> Self {
        let edits = std::fs::read(&path)
            .ok()
            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
            .unwrap_or_default();
        Self { edits, path }
    }

    pub fn push(&mut self, edit: PendingEdit) {
        self.edits.push(edit);
        self.persist();
    }

    pub fn len(&self) -> usize {
        self.edits.len() }

    pub fn is_empty(&self) -> bool { self.edits.is_empty() }

    pub fn drain_succeeded(&mut self, succeeded_indices: &[usize]) {
        let mut i = succeeded_indices.len();
        while i > 0 {
            i -= 1;
            self.edits.remove(succeeded_indices[i]);
        }
        self.persist();
    }

    fn persist(&self) {
        if let Some(parent) = self.path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Ok(json) = serde_json::to_vec_pretty(&self.edits) {
            let _ = std::fs::write(&self.path, json);
        }
    }
}
```

### Sync Status Indicator in Slint
```slint
// Source: project design (Claude's discretion for placement)
// In dashboard.slint — add near title bar area
in property <int> pending-edit-count: 0;

if root.pending-edit-count > 0 : Rectangle {
    x: parent.width - 154px - 120px;
    y: 28px;
    width: 110px;
    height: 18px;
    Text {
        text: root.pending-edit-count == 1
            ? "1 edit pending"
            : root.pending-edit-count + " edits pending";
        font-size: 11px;
        color: #f0a030;
        horizontal-alignment: center;
        vertical-alignment: center;
    }
}
```

### Slint CardData struct — required additions
```slint
// Source: dashboard.slint (must extend CardData)
struct CardData {
    // ... existing fields ...
    package-id: string,        // NEW — needed for note save + add item
    active-item-id: string,    // NEW — needed for remove item (first active item)
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `println!` stubs | Real EditDispatcher dispatch | Phase 9 | Note/item edits persist |
| No queue | Persistent pending-edit JSON file | Phase 9 | Edits survive app restart |
| No item add UI | Shipment Product Lookup modal | Phase 9 | User can add items from catalog |

**Deprecated/outdated:**
- Stub handlers at `main.rs:1007-1015`: replaced entirely in this phase.

---

## Open Questions

1. **LocalServiceClient: in-process repository access**
   - What we know: `NoopClient` is the sole `DashboardDataClient` impl; it returns `Ok` for everything but stores nothing.
   - What's unclear: Does this phase create a `LocalServiceClient` wrapping a `Rc<RefCell<Repository>>` so mutations survive within a single process run? Or does it remain on `NoopClient` with only the queue file providing cross-restart persistence?
   - Recommendation: Create `LocalServiceClient` in this phase so persistence is real (items added in one session show in the same session). Without it, optimistic UI updates work but `EditDispatcher::dispatch` effectively no-ops, making "persistence" illusory.

2. **Shopify Admin API image fetch — blocking or async?**
   - What we know: `slint::Timer` is the project's async mechanism; `reqwest` is async-capable but project has no tokio runtime.
   - What's unclear: Should image fetch be blocking (simple, blocks UI thread briefly) or spawned to a background thread?
   - Recommendation: Use a blocking HTTP client (`ureq`) on a background `std::thread::spawn`, send result back via `slint::invoke_from_event_loop`. This matches the threading model without introducing `tokio`.

3. **`on_card_remove_item` callback — which item_id?**
   - What we know: A card can have multiple items. The current `item-summary` is a string, not structured data. The Slint callback passes only a card index.
   - What's unclear: How does the user select WHICH item to remove? There is no item-level selection UI on the card currently.
   - Recommendation: For v1 with single-item cards (typical case), remove the first active item. For multi-item support, the remove flow needs a selection sub-UI within the card or modal. This is a scope decision the planner should address explicitly.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in (`#[test]`) via `cargo test` |
| Config file | none — workspace Cargo.toml |
| Quick run command | `cargo test -p app -- edit` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| ITEM-01 | `EditDispatcher` routes AddItem/RemoveItem/RenameItem and returns receipt | unit | `cargo test -p app -- edit_dispatcher` | Yes (actions.rs tests) |
| ITEM-01 | `PendingEditQueue` persists to disk and reloads across instances | unit | `cargo test -p app -- pending_edit_queue` | No — Wave 0 |
| ITEM-01 | `add_item_to_package` / `remove_item_from_package` in service API | unit | `cargo test -p service -- add_item` | Yes (api/items.rs tests) |
| ITEM-03 | `EditDispatcher` routes SaveNote and returns receipt | unit | `cargo test -p app -- edit_dispatcher_routes_save_note` | Yes (actions.rs tests) |
| ITEM-03 | `save_package_note` persists to repository | unit | `cargo test -p service -- save_package_note` | Yes (api/items.rs tests) |
| ITEM-01, ITEM-03 | Optimistic UI update mutates `all_cards_ref` correctly | unit | `cargo test -p app -- optimistic_update` | No — Wave 0 |

### Sampling Rate
- **Per task commit:** `cargo test -p app -- edit`
- **Per wave merge:** `cargo test --workspace`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `crates/app/src/dashboard/edit_queue.rs` — covers ITEM-01, ITEM-03 pending-edit queue behavior
- [ ] `crates/app/src/dashboard/edit_queue_tests` (or inline `#[cfg(test)]` block) — serialization round-trip, disk persistence, drain behavior
- [ ] `cargo add serde --features derive; cargo add serde_json; cargo add dirs` — if not already in Cargo.toml

---

## Sources

### Primary (HIGH confidence)
- Codebase: `crates/app/src/dashboard/actions.rs` — `EditCommand`, `EditDispatcher`, all variants verified with passing tests
- Codebase: `crates/service/src/api/items.rs` — `add_item_to_package`, `remove_item_from_package`, `rename_item`, `save_package_note` all verified with tests
- Codebase: `crates/service/src/db/repository.rs` — `upsert_item`, `deactivate_item`, `upsert_package`, `get_item`, `get_package` all verified
- Codebase: `crates/app/src/main.rs` lines 1007-1015 — exact stub locations confirmed
- Codebase: `crates/app/ui/card.slint` — full component reviewed; `save-note`, `add-item-clicked`, `remove-item-clicked` callbacks confirmed
- Codebase: `crates/app/ui/dashboard.slint` — `CardData` struct, `card-save-note(int, string)`, `card-add-item(int)`, `card-remove-item(int)` callbacks confirmed
- Codebase: `crates/app/src/dashboard/mod.rs` — `DashboardRuntime`, `CardEditState` states confirmed

### Secondary (MEDIUM confidence)
- Slint documentation (animate property, `iteration-count: -1` for looping) — confirmed by existing usage of `animate` in `tab-strip.slint` and `dashboard.slint`; looping behavior inferred from Slint 1.x docs pattern
- `dirs` crate: platform data directory on Windows = `%APPDATA%\Local` via `data_local_dir()` — standard crate, well-established

### Tertiary (LOW confidence)
- Slint `PopupWindow` backdrop dimming: no explicit confirmation that a sibling `Rectangle` can block underlying input. May require testing.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all libraries verified in codebase or well-established Rust ecosystem
- Architecture: HIGH — all patterns drawn directly from existing codebase conventions
- Pitfalls: HIGH — pitfalls identified by direct code inspection of the actual integration points
- Pending-edit queue design: MEDIUM — format/location is Claude's discretion; serde_json is standard

**Research date:** 2026-03-12
**Valid until:** 2026-04-12 (stable Rust/Slint codebase)
