# Phase 15: SQLite Foundation, Deprecated Field Cleanup, and UI Polish Gap Closure - Research

**Researched:** 2026-03-22
**Domain:** Rust / rusqlite / refinery migrations / struct refactoring / Slint UI
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**SQLite Connection Strategy**
- Single `Arc<Mutex<Connection>>` with WAL mode enabled
- No connection pool (r2d2-sqlite is overkill for 2-thread access pattern)
- WAL mode allows concurrent reads while one writer holds the lock
- Keep critical sections short to avoid deadlock risk
- PRAGMA foreign_keys = ON enforced at connection init

**Database Location and Schema**
- Database file at `%APPDATA%/WITwhat/witwhat.db` (same directory as config.toml)
- Schema migrations managed by `refinery` crate with numbered SQL migration files
- SQLite schema contains all DATA-FLOW.md entities: recipients, cards, card_products, notes, products, serial_instances, archive_records, pending_edits
- Schema starts clean — deprecated fields (github_profile_url, shipment_status/tracking_state on Recipient, item_summary, latest_note scalar) are never added to SQLite

**Data Migration on Upgrade**
- Fresh API fetch on first launch with empty database — no migration from in-memory state
- Show empty dashboard with sync indicator ("Syncing data...") while initial sync populates SQLite
- No dedicated splash/progress screen — cards appear as sync completes
- Existing connection indicator shows sync status

**Deprecated Field Cleanup**
- All deprecated field removal happens in the same pass as SQLite migration (not staged)
- `github_profile_url`: Remove from all structs (Recipient, RecipientCardSnapshot, RecipientSnapshot, DashboardCardViewModel). Per RULE-01.
- `shipment_status` and `tracking_state`: Remove from Recipient struct only. Card pipeline already carries these at card-level from Shopify fulfillments. Per RULE-02.
- `item_summary`: Replace with `Vec<String>` product names (simple list, no product IDs yet). Full `Vec<ProductRef>` with product IDs comes in Phase 17.
- `latest_note`: Replace with `Vec<NoteEntry { date, content }>` stored in SQLite. Full GH Issue comment sync comes in Phase 18.

**Test Strategy**
- Keep in-memory Repository for unit tests (fast, no refactoring)
- Integration tests use SQLite with `:memory:` databases
- Production code reads exclusively from SQLite (RULE-03)

**Phase 14 Gap Closure (POLISH-01)**
- Card VerticalLayout migration: execute exactly as designed in 14-CONTEXT.md
- Replace absolute y-coordinates with VerticalLayout + 8px spacing
- Card height becomes content-driven (not fixed) — cards with more items are taller
- Toast-is-warning: conditional background color wired from Rust (amber for warnings, blue for informational)

### Claude's Discretion
- Exact refinery migration file structure and naming
- SQLite PRAGMA tuning beyond WAL and foreign_keys (journal_size_limit, synchronous mode, etc.)
- Internal module organization for SQLite layer (single module vs split by entity)
- Exact VerticalLayout spacing adjustments if 8px doesn't look right after migration
- How to handle the transition period where LiveClient switches from Repository reads to SQLite reads

### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| POLISH-01 | User sees RecipientCard with vertical layout (avatar, name, status stack) and toast-is-warning wiring | **Already partially implemented** — card.slint uses VerticalLayout body and toast-is-warning is wired in main.rs; audit confirms completion or documents remaining gaps |
| PERSIST-01 | User's card data survives app restart without re-fetching from upstream | rusqlite + WAL mode enables durable local cache; run_sync_cycle writes to SQLite on every sync cycle |
| PERSIST-02 | App reads all dashboard data from SQLite, not in-memory Repository | LiveClient.fetch_card_snapshots() rewired from Repository reads to SQLite SELECT queries |
| PERSIST-03 | SQLite schema mirrors all entities defined in DATA-FLOW.md | refinery migration V001 creates all tables: recipients, cards, card_product_names, notes, products, serial_instances, archive_records, pending_edits |
| CLEAN-01 | `github_profile_url` removed from all structs and queries (RULE-01) | 40+ call sites across 15 files identified; all must be removed in one pass |
| CLEAN-02 | Shipment status/tracking fields moved off Recipient to card-level (RULE-02) | `shipment_status` and `tracking_state` on `Recipient` struct removed; already correctly propagated at card snapshot level |
| CLEAN-03 | `item_summary` replaced by `Vec<ProductRef>` on cards (RULE-07) | Replaced with `Vec<String>` product names for Phase 15; full ProductRef in Phase 17 |
| CLEAN-04 | `latest_note` replaced by `Vec<NoteEntry>` on cards (RULE-08) | NoteEntry struct introduced; SQLite notes table stores entries; GH Issue comment sync deferred to Phase 18 |
</phase_requirements>

---

## Summary

Phase 15 has three parallel workstreams: (1) SQLite foundation replacing the in-memory Repository, (2) deprecated field removal across the codebase, and (3) verification/completion of Phase 14 UI polish gaps.

**Critical pre-work discovery:** POLISH-01 is already substantially implemented. `card.slint` uses a `VerticalLayout` body block (not absolute y-coordinates), and `toast-is-warning` is set in `main.rs` at 8 distinct call sites. The planner must audit the current state before scheduling POLISH-01 work to avoid redundant tasks.

The SQLite migration is the largest workstream. The existing `LiveClient` wraps `Arc<Mutex<Repository>>` — the strategy is to replace the inner type with `Arc<Mutex<Connection>>` from rusqlite, add a `crates/service/src/db/sqlite.rs` module for CRUD operations, and wire `run_sync_cycle()` to write snapshots into SQLite tables instead of the in-memory HashMap.

The deprecated field cleanup has significant blast radius. `github_profile_url` alone spans 15 files and 40+ call sites. The field removal is the most mechanically complex part of the phase — it requires removing from domain structs, intermediate snapshots, integration structs, view models, and tests simultaneously to satisfy the compiler.

**Primary recommendation:** Sequence the work as: (1) CLEAN fields first (compiler forces correctness), (2) SQLite layer second (new module, wire into LiveClient), (3) POLISH-01 audit last (may be complete already).

---

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| rusqlite | 0.38.0 | SQLite bindings for Rust | Ergonomic, synchronous, no async overhead; `Send` semantics match single-`Arc<Mutex>` pattern |
| refinery | 0.9.0 | SQL migration runner | embed_migrations! macro compiles SQL files into binary; supports rusqlite natively |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| dirs | 5 (already in Cargo.toml) | Resolves `%APPDATA%` on Windows | Already used for config.toml path; reuse for db file path |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| refinery | rusqlite_migration | rusqlite_migration is simpler but refinery has better SQL file embedding and report API |
| refinery | diesel migrations | diesel pulls in full ORM; overkill for direct SQL pattern here |
| Arc<Mutex<Connection>> | r2d2-sqlite | r2d2-sqlite adds connection pool complexity; unnecessary for 2-thread (UI + background sync) access |

**Installation** (add to `crates/service/Cargo.toml`):
```toml
rusqlite = { version = "0.38", features = ["bundled"] }
refinery = { version = "0.9", features = ["rusqlite"] }
```

Note: `bundled` feature statically links SQLite — avoids dependency on system SQLite, which may be absent or outdated on target Windows machines.

---

## Architecture Patterns

### Recommended Project Structure

```
crates/service/src/db/
├── repository.rs          # In-memory Repository (test-only — no production changes)
├── sqlite.rs              # NEW: SqliteStore — production read/write layer
├── models.rs              # Existing aggregate structs (keep as-is)
└── migrations/
    └── V001__initial_schema.sql   # NEW: full schema for all DATA-FLOW.md entities
```

### Pattern 1: SqliteStore — Wraps Arc<Mutex<Connection>>

**What:** A `SqliteStore` struct wraps the single shared connection. All SQL reads and writes go through its methods. `LiveClient` holds an `Arc<SqliteStore>` instead of `Arc<Mutex<Repository>>`.

**When to use:** Production code only. Tests use Repository (in-memory) or `SqliteStore::open_in_memory()`.

**Example:**
```rust
// Source: rusqlite docs — Connection is Send but not Sync
pub struct SqliteStore {
    conn: Mutex<Connection>,
}

impl SqliteStore {
    pub fn open(path: &std::path::Path) -> Result<Self, rusqlite::Error> {
        let conn = Connection::open(path)?;
        conn.execute_batch(
            "PRAGMA journal_mode = WAL;
             PRAGMA foreign_keys = ON;
             PRAGMA synchronous = NORMAL;"
        )?;
        Ok(Self { conn: Mutex::new(conn) })
    }

    pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
        let conn = Connection::open_in_memory()?;
        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
        Ok(Self { conn: Mutex::new(conn) })
    }
}
```

Note: `SqliteStore` itself is `Send + Sync` because `Mutex<Connection>` is `Sync`. Wrap in `Arc<SqliteStore>` to share between threads.

### Pattern 2: refinery Migration Embedding

**What:** SQL files in `migrations/` are compiled into the binary via `embed_migrations!` macro. `runner().run(&mut conn)` applies unapplied migrations on startup.

**When to use:** On every app startup, before any reads or writes.

```rust
// Source: refinery 0.9.0 docs
mod embedded {
    use refinery::embed_migrations;
    embed_migrations!("src/db/migrations");
}

// In SqliteStore::open():
let mut guard = self.conn.lock().unwrap();
embedded::migrations::runner().run(&mut *guard)?;
```

Migration file naming: `V{version}__{name}.sql` (two underscores)
- `V001__initial_schema.sql` — creates all tables
- `V002__add_column.sql` — future additions (never ALTER in V001)

### Pattern 3: LiveClient Rewire

**What:** Replace `repo: Arc<Mutex<Repository>>` with `store: Arc<SqliteStore>`. The `fetch_card_snapshots()` method reads from SQLite SELECT instead of in-memory HashMap. `run_sync_cycle()` writes sync results to SQLite upsert methods.

**Key callsites to change:**
- `LiveClient::new()` — init `SqliteStore` instead of `Repository`
- `LiveClient::new_for_test()` — use `SqliteStore::open_in_memory()`
- `LiveClient::repo()` — remove or replace with `store()` accessor
- `DashboardDataClient::fetch_card_snapshots()` — read from SQLite
- `run_sync_cycle()` result handler — write to SQLite instead of `unassigned_bg`
- `save_note()`, `add_item()`, `remove_item()`, `rename_item()` — write to SQLite

### Pattern 4: Deprecated Field Removal — Compilation-Driven

**What:** Remove deprecated fields from the root struct first, then follow the compiler errors outward through all intermediate structs and usages.

**Order of removal for `github_profile_url`:**
1. `integrations/src/github/project_mapping.rs` — `GithubMappedRecipient.github_profile_url` (field used to derive recipient_key — keep recipient_key derivation logic, drop the field itself)
2. `core/src/domain/recipient.rs` — `Recipient.github_profile_url`
3. `service/src/api/recipients.rs` — `RecipientSnapshot.github_profile_url`
4. `app/src/service_client.rs` — `RecipientCardSnapshot.github_profile_url`
5. `app/src/dashboard/view_model.rs` — `DashboardCardViewModel.github_profile_url`
6. All construction sites: `live_client.rs`, `discovery.rs`, `projection.rs`, `archive.rs`, `shopify_linker.rs`, `merge.rs`, `customer_lookup.rs`
7. All test files: `api_smoke_tests.rs`, `github_ingest_tests.rs`, `shopify_sync_tests.rs`, `repository_persistence_tests.rs`, `merge_pipeline_tests.rs`, `dashboard_projection_tests.rs`

**Order of removal for `shipment_status` / `tracking_state` on Recipient:**
1. `core/src/domain/recipient.rs` — remove both fields
2. `service/src/api/recipients.rs` — remove from RecipientSnapshot, fix `last_status_update` derivation (read from package.shipment_status instead of recipient.shipment_status)
3. All Recipient construction sites across tests

### Anti-Patterns to Avoid

- **Don't remove `github_profile_url` from `GithubMappedRecipient` without preserving recipient_key derivation.** The URL was used to extract the GitHub username as `recipient_key`. The extraction logic must survive; only the stored URL field is removed. After cleanup, `GithubMappedRecipient` derives `recipient_key` purely from the URL parsing but stops carrying the raw URL.
- **Don't write sync results to both SQLite AND in-memory store.** Once SQLite is wired, the in-memory Repository is removed from production `LiveClient`. Dual-write creates divergent state.
- **Don't hold the `Mutex<Connection>` lock across async boundaries or sleep loops.** Lock, execute query, unlock. Never hold through a network call.
- **Don't run refinery's `runner().run()` on every query.** Run once at startup in `SqliteStore::open()`. Refinery is idempotent (tracks applied versions in `refinery_schema_history` table).

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Schema version tracking | Custom `schema_version` table + version checks | refinery | refinery handles divergent/missing migration detection, report API, and version history automatically |
| Connection thread-safety | Custom lock wrapper | `Mutex<Connection>` | rusqlite `Connection` is `Send` — `Mutex` gives the needed `Sync`; no additional layer required |
| SQLite file path resolution | Custom path builder | `dirs::config_dir().join("WITwhat").join("witwhat.db")` | Already used for config.toml in `config.rs`; reuse exact pattern |
| WAL pragma setup | Manual file locking | `PRAGMA journal_mode = WAL` | SQLite WAL is well-tested; no custom locking needed |

**Key insight:** The in-memory Repository has already been the "custom solution" — SQLite replaces it with a proven persistence layer. The risk is over-engineering the SQLite layer itself when direct rusqlite queries (no ORM) are sufficient for the 5-entity schema.

---

## Common Pitfalls

### Pitfall 1: Mutex Deadlock from Nested Locks

**What goes wrong:** `fetch_card_snapshots()` locks `store.conn`, then internally calls another method that also tries to lock `store.conn`. Instant deadlock.

**Why it happens:** Rust `Mutex` is not re-entrant. Locking twice from the same thread panics or deadlocks.

**How to avoid:** All SQL operations must be implemented as single-lock-scope functions. Compose read/write operations within one `lock().unwrap()` guard scope. Never call one `SqliteStore` method from within another while holding the lock.

**Warning signs:** Methods that accept `&self` and call other `&self` methods with `conn.lock()` inside.

### Pitfall 2: WAL Mode Not Persisted After Re-open

**What goes wrong:** `PRAGMA journal_mode = WAL` must be set on EVERY connection open — it's not a persistent database-level setting that survives close/reopen on all SQLite versions.

**Why it happens:** WAL mode is stored in the database file header, but some pragmas reset on connection close.

**How to avoid:** Always run the PRAGMA batch inside `SqliteStore::open()` before any other operations. refinery's `runner().run()` call should come AFTER the PRAGMA batch.

**Warning signs:** Occasional write conflicts or lock errors on second app launch.

### Pitfall 3: `github_profile_url` Partial Removal — Compiler False Positive

**What goes wrong:** Removing `github_profile_url` from `Recipient` compiles successfully if all construction sites use `..Default::default()` for missing fields. The field lingers in test helpers using struct literal syntax.

**Why it happens:** Rust's exhaustive pattern matching only catches missing fields in struct literals, not in `..rest` spreads.

**How to avoid:** After removing the field from the struct, grep for `github_profile_url` across the entire codebase and manually verify each remaining reference is removed. The field must not appear in ANY `.rs` file (except perhaps a comment explaining the removal).

**Warning signs:** Post-cleanup grep returns hits in test files.

### Pitfall 4: `item_summary` Still Referenced in Slint

**What goes wrong:** `RecipientCardSnapshot.item_summary` is removed from Rust, but `card.slint` still has `in property <string> item-summary` and `dashboard.slint` passes `card-data.item-summary`. This causes a Slint compilation error.

**Why it happens:** Slint compilation happens separately from Rust; compiler errors surface at build time but can be confusing to trace.

**How to avoid:** When replacing `item_summary` with `Vec<String>` in Rust, simultaneously update `card.slint` to use `in property <[string]> item-names` (or similar) and update `dashboard.slint` CardData struct to match.

**Warning signs:** slint-build build.rs errors mentioning unknown property.

### Pitfall 5: POLISH-01 Already Partially Done

**What goes wrong:** Planner schedules VerticalLayout migration and toast-is-warning wiring as tasks — implementer finds both are already done, wastes time auditing instead of delivering.

**Why it happens:** Phase 14 work may have been partially completed before Phase 15 planning.

**How to avoid:** The planner's Wave 0 for POLISH-01 must be a verification audit task that checks the current state of `card.slint` (confirmed: VerticalLayout body exists) and `main.rs` (confirmed: `set_toast_is_warning(true/false)` is called at 8+ sites). If both are complete, POLISH-01 closes immediately with documentation.

**Warning signs:** VerticalLayout body found in card.slint at line 203; `set_toast_is_warning` found in main.rs at lines 1039, 1211, 1297, 1474, 2188, 2218, 2242, 2325, 2351, 2392.

---

## Code Examples

Verified patterns from official sources and codebase inspection:

### SQLite Connection Init with WAL + Refinery
```rust
// Source: rusqlite docs + refinery 0.9.0 docs
use rusqlite::Connection;

mod embedded {
    use refinery::embed_migrations;
    embed_migrations!("src/db/migrations");
}

pub fn open_production(path: &std::path::Path) -> Result<Connection, rusqlite::Error> {
    let conn = Connection::open(path)?;
    conn.execute_batch(
        "PRAGMA journal_mode = WAL;
         PRAGMA foreign_keys = ON;
         PRAGMA synchronous = NORMAL;"
    )?;
    embedded::migrations::runner().run(&mut conn).expect("migrations failed");
    Ok(conn)
}
```

### Database Path (Reusing Existing Pattern)
```rust
// Source: crates/app/src/config.rs — config_path() pattern
pub fn db_path() -> Option<std::path::PathBuf> {
    dirs::config_dir().map(|d| d.join("WITwhat").join("witwhat.db"))
}
```

### V001__initial_schema.sql — Core Tables (Schema Skeleton)
```sql
-- V001__initial_schema.sql
-- NOTE: github_profile_url, shipment_status, tracking_state on recipients are
-- intentionally absent (RULE-01, RULE-02). item_summary absent (RULE-07).
-- latest_note scalar absent (RULE-08).

CREATE TABLE IF NOT EXISTS recipients (
    recipient_id        TEXT PRIMARY KEY,
    github_item_id      TEXT,
    shopify_customer_id TEXT,
    discord_username    TEXT,
    discord_user_id     TEXT,
    version             INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE IF NOT EXISTS cards (
    card_id             TEXT PRIMARY KEY,   -- recipient_key or "unassigned:order:{id}"
    recipient_id        TEXT REFERENCES recipients(recipient_id),
    shopify_order_id    TEXT,
    shopify_customer_id TEXT,
    shipment_status     TEXT,
    shipment_status_date TEXT,
    partial_data        INTEGER NOT NULL DEFAULT 0,  -- bool as 0/1
    is_unassigned       INTEGER NOT NULL DEFAULT 0,
    unassigned_customer_name TEXT,
    last_updated_at     INTEGER   -- Unix timestamp millis, NULL if unknown
);

CREATE TABLE IF NOT EXISTS card_product_names (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    card_id     TEXT NOT NULL REFERENCES cards(card_id),
    position    INTEGER NOT NULL,
    name        TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS notes (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    card_id     TEXT NOT NULL REFERENCES cards(card_id),
    note_date   TEXT NOT NULL,   -- ISO 8601 string
    content     TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS products (
    product_id          TEXT PRIMARY KEY,
    name                TEXT NOT NULL,
    image_url           TEXT,
    shopify_product_url TEXT,
    is_serializable     INTEGER NOT NULL DEFAULT 0,
    github_issue_id     TEXT
);

CREATE TABLE IF NOT EXISTS serial_instances (
    serial_id       TEXT NOT NULL,
    product_id      TEXT NOT NULL REFERENCES products(product_id),
    state           TEXT NOT NULL DEFAULT 'Created',
    assigned_card_id TEXT REFERENCES cards(card_id),
    assigned_at     TEXT,
    PRIMARY KEY (serial_id, product_id)
);

CREATE TABLE IF NOT EXISTS archive_records (
    card_id         TEXT PRIMARY KEY REFERENCES cards(card_id),
    archive_state   INTEGER NOT NULL DEFAULT 0,  -- 0=Active,1=TBA,2=Archived
    archived_at     TEXT
);

CREATE TABLE IF NOT EXISTS pending_edits (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    entity_type TEXT NOT NULL,  -- "card", "product", etc.
    entity_id   TEXT NOT NULL,
    edit_type   TEXT NOT NULL,  -- "add_item", "remove_item", "rename_item", "save_note"
    payload     TEXT NOT NULL,  -- JSON
    created_at  TEXT NOT NULL
);
```

### NoteEntry Struct (Replacing latest_note)
```rust
// New struct to replace Option<String> latest_note
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoteEntry {
    pub date: String,       // ISO 8601 date string
    pub content: String,
}
```

### RecipientCardSnapshot After Cleanup
```rust
// After removing github_profile_url, item_summary, latest_note, first_item_image_hint
pub struct RecipientCardSnapshot {
    pub recipient_id: String,
    pub recipient_name: String,
    pub discord_username: Option<String>,
    pub discord_user_id: Option<String>,
    // github_profile_url REMOVED (RULE-01)
    pub shopify_customer_id: Option<String>,
    pub shopify_order_id: Option<String>,
    pub email: Option<String>,
    pub shipment_status: Option<String>,
    pub shipment_status_date: Option<String>,
    pub product_names: Vec<String>,        // replaces item_summary (RULE-07)
    pub notes: Vec<NoteEntry>,             // replaces latest_note (RULE-08)
    // first_item_image_hint REMOVED (DATA-FLOW.md: "Retired")
    pub partial_data: bool,
    pub last_updated_at: Option<SystemTime>,
    pub is_unassigned: bool,
    pub unassigned_customer_name: Option<String>,
}
```

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| In-memory HashMap `Repository` | rusqlite `SqliteStore` | Phase 15 | Data survives restart; enables PERSIST-01 |
| `item_summary: String` (pipe-separated) | `product_names: Vec<String>` | Phase 15 | Type-safe, extensible; prepares for ProductRef in Phase 17 |
| `latest_note: Option<String>` | `notes: Vec<NoteEntry>` | Phase 15 | Full note history; prepares for GH Issue comment sync in Phase 18 |
| `github_profile_url` on 5 structs | Removed entirely | Phase 15 | RULE-01 compliance; no GH column exists |
| `shipment_status` / `tracking_state` on `Recipient` | Removed from Recipient | Phase 15 | RULE-02 compliance; shipment lives at card level only |

**Deprecated/outdated:**
- `repository.rs` production use: deprecated as primary data store; becomes test-only
- `LiveClient.repo()` accessor: remove or make test-only once SQLite is wired
- `service::api::recipients::get_recipient_snapshot(&repo, id)` in production `fetch_card_snapshots()`: replaced by SQLite SELECT

---

## Open Questions

1. **`github_profile_url` used for recipient_key derivation in GithubMappedRecipient**
   - What we know: `project_mapping.rs` uses `github_profile_url` to extract a GitHub username as `recipient_key`. The field is stored on `GithubMappedRecipient` as a raw URL.
   - What's unclear: After removing the field, does `GithubMappedRecipient` still need to store the derived `recipient_key`? Yes — `recipient_key` is already a separate field. The URL field can be dropped; the derivation logic remains.
   - Recommendation: Remove `GithubMappedRecipient.github_profile_url` field. Keep `recipient_key: String`. Derivation logic in `map_rows()` is internal — the raw URL never needs to be stored externally.

2. **`first_item_image_hint` on RecipientCardSnapshot**
   - What we know: DATA-FLOW.md marks it as "Retired" with note "Products shown as individual image squares, not a single hint URL."
   - What's unclear: Is it still actively used in the Slint pipeline? (card.slint has `in property <string> image-hint` and main.rs maps it)
   - Recommendation: Remove `first_item_image_hint` from `RecipientCardSnapshot` as part of CLEAN-03 work. Update `card.slint` and `main.rs` mapping simultaneously. If cards display correctly without it (item squares are already rendered), removal is safe.

3. **`save_note` / item mutations: write-through to SQLite or pending queue?**
   - What we know: Current implementation writes to in-memory Repository. Target is SQLite. `pending_edits` table is in the schema.
   - What's unclear: Should Phase 15 implement full write-through to SQLite `notes` table, or write to `pending_edits` queue for future GH Issue sync?
   - Recommendation: Phase 15 writes notes directly to SQLite `notes` table (immediate persistence). `pending_edits` queue is wired in Phase 20 (offline mode). This keeps Phase 15 scope contained.

---

## Validation Architecture

> `workflow.nyquist_validation` key is absent from `.planning/config.json` — treated as enabled.

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[test]` (cargo test) |
| Config file | None — cargo test discovers by convention |
| Quick run command | `cargo test -p service -- sqlite` |
| Full suite command | `cargo test --workspace` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PERSIST-01 | Data survives restart | integration | `cargo test -p service -- sqlite_persistence` | ❌ Wave 0 |
| PERSIST-02 | All reads from SQLite | unit | `cargo test -p service -- sqlite_read` | ❌ Wave 0 |
| PERSIST-03 | Schema mirrors DATA-FLOW.md entities | integration | `cargo test -p service -- schema_tables` | ❌ Wave 0 |
| CLEAN-01 | github_profile_url absent from all structs | compile-time | `cargo build --workspace` | N/A — compiler enforces |
| CLEAN-02 | shipment_status absent from Recipient | compile-time | `cargo build --workspace` | N/A — compiler enforces |
| CLEAN-03 | product_names Vec<String> on snapshots | unit | `cargo test -p app -- product_names` | ❌ Wave 0 |
| CLEAN-04 | Vec<NoteEntry> on snapshots | unit | `cargo test -p service -- note_entry` | ❌ Wave 0 |
| POLISH-01 | VerticalLayout + toast-is-warning | manual | Visual inspection in running app | N/A — audit required |

### Sampling Rate
- **Per task commit:** `cargo test --workspace`
- **Per wave merge:** `cargo test --workspace`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `crates/service/src/db/sqlite.rs` — SqliteStore struct with `open()`, `open_in_memory()`, CRUD methods
- [ ] `crates/service/src/db/migrations/V001__initial_schema.sql` — schema for all entities
- [ ] `crates/service/tests/sqlite_persistence_tests.rs` — covers PERSIST-01, PERSIST-02, PERSIST-03
- [ ] `crates/service/tests/note_entry_tests.rs` — covers CLEAN-04 NoteEntry struct and storage
- [ ] Add `rusqlite = { version = "0.38", features = ["bundled"] }` to `crates/service/Cargo.toml`
- [ ] Add `refinery = { version = "0.9", features = ["rusqlite"] }` to `crates/service/Cargo.toml`

---

## Codebase Snapshot — Deprecated Field Blast Radius

These files require changes for CLEAN-01 (`github_profile_url` removal):

**Domain / integration structs (remove field):**
- `crates/core/src/domain/recipient.rs` — `Recipient.github_profile_url`
- `crates/integrations/src/github/project_mapping.rs` — `GithubMappedRecipient.github_profile_url`
- `crates/integrations/src/shopify/customer_lookup.rs` — `github_profile_url` field + usage
- `crates/service/src/api/recipients.rs` — `RecipientSnapshot.github_profile_url`
- `crates/service/src/sync/merge.rs` — mapping that sets `github_profile_url: Some(input.github.github_profile_url)`
- `crates/service/src/sync/shopify_linker.rs` — `github_profile_url` field on local struct
- `crates/service/src/sync/github_ingest.rs` — comparison involving `github_profile_url`
- `crates/app/src/service_client.rs` — `RecipientCardSnapshot.github_profile_url`
- `crates/app/src/dashboard/view_model.rs` — `DashboardCardViewModel.github_profile_url`
- `crates/app/src/dashboard/projection.rs` — mapping `github_profile_url: snapshot.github_profile_url`

**Construction sites (remove field from struct literals):**
- `crates/app/src/live_client.rs` — ~5 construction sites
- `crates/app/src/dashboard/discovery.rs` — 3 construction sites
- `crates/app/src/dashboard/archive.rs` — 1 construction site

**Test files (update struct literals and assertions):**
- `crates/service/tests/api_smoke_tests.rs`
- `crates/service/tests/github_ingest_tests.rs`
- `crates/service/tests/merge_pipeline_tests.rs`
- `crates/service/tests/repository_persistence_tests.rs`
- `crates/service/tests/shopify_sync_tests.rs`
- `crates/app/tests/dashboard_projection_tests.rs`

**Note for main.rs:** `main.rs` line 250 already has a comment `// github_profile_url not mapped: View GitHub was scoped out` — no active mapping code remains.

---

## Sources

### Primary (HIGH confidence)
- rusqlite 0.38.0 — https://docs.rs/rusqlite/latest/rusqlite/struct.Connection.html — WAL pragma setup, Send/Sync thread safety
- refinery 0.9.0 — https://docs.rs/refinery/0.9.0/refinery/ — embed_migrations! macro, runner().run() API, file naming
- `.planning/DATA-FLOW.md` — authoritative field status (KEEP/REMOVE/REPLACE) for all entities
- `crates/app/src/service_client.rs` — RecipientCardSnapshot current field list (verified by Read)
- `crates/core/src/domain/recipient.rs` — Recipient struct with deprecated fields (verified by Read)
- `crates/app/ui/card.slint` lines 203-296 — VerticalLayout already implemented (verified by Read)
- `crates/app/src/main.rs` — set_toast_is_warning calls at 8+ sites (verified by Grep)

### Secondary (MEDIUM confidence)
- crates.io rusqlite — version 0.38.0 confirmed as latest stable (December 2025)
- crates.io refinery — version 0.9.0 confirmed as latest stable
- WebSearch: rusqlite WAL + Arc<Mutex<Connection>> pattern confirmed as standard community approach for 2-thread access

### Tertiary (LOW confidence)
- `PRAGMA synchronous = NORMAL` tuning recommendation — commonly cited with WAL mode but not independently verified against rusqlite docs; safe default, can be adjusted

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — versions verified against crates.io, docs.rs
- Architecture: HIGH — codebase directly inspected; patterns confirmed against rusqlite/refinery docs
- Deprecated field blast radius: HIGH — grep confirmed all 15 files and 40+ sites
- POLISH-01 status: HIGH — card.slint and main.rs directly read; both are substantially complete
- Pitfalls: HIGH — rusqlite thread-safety from official docs; Mutex re-entrancy is a known Rust behavior

**Research date:** 2026-03-22
**Valid until:** 2026-06-22 (rusqlite/refinery are stable crates; 90-day window is conservative)
