# Phase 18: GH Issues Write-Back, Notes, and Card Cloud Storage - Research

**Researched:** 2026-03-27
**Domain:** Rust async patterns, GitHub CLI subprocess API, SQLite migration, background timer with retry/backoff
**Confidence:** HIGH — all key structures, APIs, and integration points verified from source code

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Full snapshot body — includes identity AND mutable state fields. GH Issue body is the only cloud location for card-level data.
- **D-02:** Body JSON format:
  ```json
  {
    "schema_version": 1,
    "card_id": "pkg_abc123",
    "recipient_key": "john-doe",
    "shopify_order_id": "6012345",
    "product_refs": [
      { "product_id": "prod_1", "serial_id": "HMD-001" }
    ],
    "shipment_status": "InTransit",
    "shipment_status_date": "2026-03-15"
  }
  ```
- **D-03:** Body updated when product_refs or shipment_status change (via PendingEditFlusher).
- **D-04:** Add `CardIssueBody` struct alongside `ProductIssueBody` / `ProductUnitIssueBody` in `issues_client.rs`, with `parse_card_body()` / `format_card_body()` helpers.
- **D-05:** Eager creation on first sync — any card in SQLite without a `github_issue_number` gets a `ww-card` issue created during startup sync.
- **D-06:** Issue title format: `{Recipient Name} - {First Product Name} {Unit Serial if Relevant} (+{Remaining Product Count}) [#{Order ID}]`
- **D-07:** Issue label: `ww-card`
- **D-08:** Add `github_issue_number: Option<i64>` column to `cards` table (V006 migration).
- **D-09:** Write-only sync direction for Phase 18. Add `synced_at` column to `notes` table for future bidirectional design.
- **D-10:** Backfill existing notes on issue creation — post all in chronological order, mark all as synced.
- **D-11:** GH comment format: `` `ww-note` `` code tag + italic quote block.
- **D-12:** Each note = one GH Issue comment.
- **D-13:** Add `synced_at: Option<String>` column to `notes` table (V006 migration). NULL = unsynced.
- **D-14:** Hybrid flush — try immediately on edit, queue failures to SQLite `pending_edits` table, timer retries every 60s with exponential backoff.
- **D-15:** Migrate from JSON file (`pending_edits.json`) to SQLite `pending_edits` table. Delete old file, start fresh (no migration of existing entries).
- **D-16:** Edit types for GH write-back: `SaveNote` → comment, `UpdateCardBody` → body edit, `UpdateCardTitle` → title edit.
- **D-17:** On flush failure: increment `retry_count`, exponential backoff (60s, 120s, 240s, cap ~15min).
- **D-18:** Flusher runs as a background timer alongside normal app operation. Non-blocking to UI.

### Claude's Discretion

- Exact backoff cap and retry limit for PendingEditFlusher
- Internal batching strategy (per-card grouping, ordering)
- Migration file numbering (next available V00X)
- Whether to coalesce multiple body updates into a single edit before flushing

### Deferred Ideas (OUT OF SCOPE)

- Bidirectional note sync (GH comments → app): design for it (`synced_at` column), implement later
- Product unit ownership audit trail via GH issue timeline comments
- Auto-promote recipient GH Project rows from drafts to ww-recipient issues
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| CLOUD-01 | System can create GH Issues tagged `ww-card` for recipient card records | `GhIssuesClient.create_issue()` already implemented in Phase 17; needs `CardIssueBody` struct + `sync_cards()` function following `sync_products()` pattern |
| CLOUD-02 | System can update `ww-card` GH Issues when card data changes | `GhIssuesClient.edit_issue_body()` already implemented; needs title-edit method + PendingEditFlusher to queue/retry updates |
| CLOUD-05 | Notes stored as GH Issue comments on `ww-card` issues, synced as `Vec<NoteEntry>` | New `create_issue_comment()` method needed on `GhIssuesClient`; `synced_at` column on `notes` table; note save path extended to queue GH comment |
</phase_requirements>

---

## Summary

Phase 18 wires the final cloud persistence layer for cards. The three required capabilities are: (1) creating `ww-card` GH Issues for every card during startup sync, (2) updating those issues when card data changes, and (3) posting notes as GH Issue comments. All of this is mediated by a new `PendingEditFlusher` that replaces the existing JSON-file queue with SQLite-backed storage plus an immediate-attempt hybrid strategy.

The codebase already has all the building blocks. `GhIssuesClient` from Phase 17 has `create_issue()`, `edit_issue_body()`, and `list_issues_by_label()`. The `sync_products()` function in `live_client.rs` is the canonical pattern for wiring issue sync into the startup flow. The SQLite `pending_edits` table already exists in V001 but is not wired to any code — it is exactly what D-15 needs. The `notes` table lacks only the `synced_at` column. The `cards` table lacks only `github_issue_number`.

The highest-complexity work is `PendingEditFlusher`: it needs a background thread with a 60-second timer, exponential backoff per row, thread-safe access to `SqliteStore`, and the ability to call `GhIssuesClient` methods. This is the pattern used by the sync timer but with per-row retry state rather than a global cycle.

**Primary recommendation:** Follow the `sync_products()` precedent for card issue creation. Build `PendingEditFlusher` as a new module in `crates/app/src/dashboard/` (parallel to `edit_queue.rs`). Replace `edit_queue.rs` usage sites after the flusher is wired.

---

## Standard Stack

### Core (already in project — no new deps needed)

| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| `rusqlite` | 0.32 (bundled) | SQLite CRUD for `pending_edits`, `notes`, `cards` migrations | Already in `crates/service` |
| `refinery` | 0.8 | Embedded SQL migration runner | Already manages V001–V005 |
| `serde` / `serde_json` | workspace | `CardIssueBody` serialization, pending edit payload JSON | Already in all crates |
| `gh` CLI | system binary | Transport for `create_issue`, `edit_issue_body`, new `comment` command | Located via `find_gh()` |
| `chrono`-free date util | (inline) | ISO 8601 date string for `synced_at` | `chrono_free_date()` already in `live_client.rs` |

### New Method Needed

`GhIssuesClient` does NOT have `create_issue_comment()` or `edit_issue_title()`. Both must be added.

**`gh` CLI commands to use:**
```bash
# Create a comment on an issue
gh issue comment {number} --repo {owner}/{repo} --body-file {path}

# Edit an issue title
gh issue edit {number} --repo {owner}/{repo} --title "{new title}"
```

Both are supported by `gh` CLI. Confidence: HIGH (verified against gh CLI interface pattern used by existing `create_issue` and `edit_issue_body`).

---

## Architecture Patterns

### Recommended Module Layout

```
crates/app/src/dashboard/
├── edit_queue.rs        # REMOVE or hollow out after flusher wired
└── pending_edit_flusher.rs   # NEW: PendingEditFlusher, PendingEdit enum (redefine for Phase 18)

crates/integrations/src/github/
└── issues_client.rs     # EXTEND: CardIssueBody, parse_card_body(), format_card_body(),
                         #         create_issue_comment(), edit_issue_title()

crates/service/src/db/migrations/
└── V006__card_issue_and_note_sync.sql   # NEW: github_issue_number on cards, synced_at on notes,
                                          #      retry_count on pending_edits
```

### Pattern 1: `sync_cards()` following `sync_products()` precedent

**What:** Startup-time eager issue creation for cards missing `github_issue_number`.
**When to use:** Called inside `run_sync_cycle()` at Step 5b (after product sync, before returning).
**Key differences from `sync_products()`:**
- Does NOT list existing `ww-card` issues to match (no stale cleanup needed — cards are not deleted in Phase 18).
- Reads cards from SQLite, filters `WHERE github_issue_number IS NULL`, creates issue, updates SQLite column.
- Backfills existing notes: after issue creation, post each note as a comment in chronological order, mark `synced_at`.

```rust
// Source: crates/app/src/live_client.rs (sync_products pattern)
pub fn sync_card_issues(
    issues_client: &GhIssuesClient,
    store: &SqliteStore,
) -> Result<usize, String> {
    let cards = store.read_all_cards().map_err(|e| e.to_string())?;
    let mut count = 0;
    for card in &cards {
        if card.github_issue_number.is_some() { continue; }
        let title = format_card_title(&card);
        let body = format_card_body(&build_card_issue_body(&card));
        match issues_client.create_issue(&title, &body, "ww-card") {
            Ok(created) => {
                store.set_card_issue_number(&card.card_id, created.number)
                    .map_err(|e| e.to_string())?;
                backfill_card_notes(issues_client, store, &card.card_id, created.number)?;
                count += 1;
            }
            Err(e) => eprintln!("[sync] Failed to create ww-card issue for {}: {:?}", card.card_id, e),
        }
    }
    Ok(count)
}
```

### Pattern 2: `PendingEditFlusher` — background timer with per-row backoff

**What:** Replaces `PendingEditQueue` (JSON file). Reads rows from SQLite `pending_edits` table, attempts GH API call, removes on success, increments `retry_count` on failure.
**Key design:**
- Spawned as a background thread in `LiveClient::new()` alongside the existing sync thread.
- Holds `Arc<SqliteStore>` and `Arc<GhIssuesClient>` (the same instance used by sync).
- Timer: sleeps 60s between flush passes.
- Per-row backoff: skip rows where `last_attempted_at + backoff_duration(retry_count) > now`.
- Backoff cap (Claude's discretion): max 15 attempts, cap at 900s (15 min).
- On success: `DELETE FROM pending_edits WHERE id = ?`.
- On failure: `UPDATE pending_edits SET retry_count = retry_count + 1, last_attempted_at = ? WHERE id = ?`.

```rust
// Backoff strategy (Claude's discretion)
fn backoff_secs(retry_count: i32) -> u64 {
    let base = 60u64;
    let cap = 900u64; // 15 minutes
    (base * (1 << retry_count.min(4) as u64)).min(cap)
}
// Results: 60, 120, 240, 480, 900, 900, 900... (caps at 900s)
```

**Edit payload structure for SQLite `pending_edits` table:**
- `entity_type`: `"card"`
- `entity_id`: `card_id`
- `edit_type`: `"SaveNote"` | `"UpdateCardBody"` | `"UpdateCardTitle"`
- `payload`: JSON string with edit-specific data
- `retry_count`: INTEGER (add via V006 migration)
- `last_attempted_at`: TEXT ISO 8601 (add via V006 migration)
- `created_at`: TEXT ISO 8601 (already in V001 schema)

### Pattern 3: Note save path extension

**Current flow:**
```
UI save-note callback
  → EditDispatcher.dispatch(SaveNote)
  → LiveClient.save_note()
  → SqliteStore.save_note()  ← stops here
```

**Phase 18 extended flow:**
```
UI save-note callback
  → EditDispatcher.dispatch(SaveNote)
  → LiveClient.save_note()
  → SqliteStore.save_note()       ← SQLite write (unchanged)
  → read card's github_issue_number from SQLite
  → if Some(number): try immediate GH comment create
      → on success: update notes.synced_at
      → on failure: insert into pending_edits (SaveNote payload)
  → if None: insert into pending_edits (SaveNote payload, will flush after issue creation)
```

The immediate attempt should be fire-and-forget on the same background thread that dispatches the edit (not blocking the UI). The pattern from Phase 16 `update_recipient_field` (spawn background thread, log failure) applies here.

### Pattern 4: `CardIssueBody` struct in `issues_client.rs`

**What:** Follows the `ProductIssueBody` / `ProductUnitIssueBody` convention exactly.

```rust
// Source: issues_client.rs (ProductIssueBody pattern — HIGH confidence)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CardIssueBody {
    pub schema_version: u32,
    pub card_id: String,
    pub recipient_key: String,
    pub shopify_order_id: Option<String>,
    pub product_refs: Vec<CardProductRef>,
    pub shipment_status: Option<String>,
    pub shipment_status_date: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CardProductRef {
    pub product_id: String,
    pub serial_id: Option<String>,
}
```

`parse_card_body()` and `format_card_body()` follow `parse_product_body()` / `format_issue_body_json()` exactly.

### Anti-Patterns to Avoid

- **Blocking the UI thread on GH API calls:** All GH calls must happen on background threads. The immediate-attempt path in note save must be spawned off (`std::thread::spawn`). See Phase 16 fire-and-forget pattern.
- **Nested Mutex locks on `SqliteStore`:** Single `Arc<Mutex<Connection>>` — never call two `SqliteStore` methods holding a lock (each method locks/unlocks independently). The flusher must not hold any lock between SQLite reads and GH API calls.
- **Keeping `AddItem`/`RemoveItem`/`RenameItem` in the new `PendingEdit` enum:** These variants are legacy from `edit_queue.rs` and not part of Phase 18 GH write-back. The new `PendingEdit` for the SQLite-backed flusher has only three variants: `SaveNote`, `UpdateCardBody`, `UpdateCardTitle`.
- **Writing `github_issue_number` to CardRow on every upsert_card:** Add a targeted `set_card_issue_number()` method instead of touching the full upsert path — minimizes migration blast radius.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| GH API transport | HTTP client, GraphQL | `gh` CLI subprocess via `GhIssuesClient` | Established project pattern; handles auth transparently |
| JSON body parsing | Custom string parser | `parse_issue_body_json()` + serde | Already handles `\`\`\`json` fences + bare JSON |
| SQLite migrations | Manual CREATE TABLE IF NOT EXISTS | refinery V006 migration file | Migrations are embedded and run automatically at startup |
| Exponential backoff | Custom sleep math | Simple bit-shift formula (see Pattern 2) | 4 lines, no external dep |
| ISO 8601 timestamp | `chrono` crate | `chrono_free_date()` (already in live_client.rs) | No new dependency needed |

---

## Common Pitfalls

### Pitfall 1: Creating a `ww-card` issue for unassigned cards

**What goes wrong:** `sync_card_issues()` iterates all cards from SQLite. Unassigned cards (`is_unassigned = true`) have `card_id = "unassigned:order:{id}"` and no recipient name — creating an issue for them would produce garbage entries.
**Why it happens:** The query `WHERE github_issue_number IS NULL` matches unassigned cards too.
**How to avoid:** Filter `WHERE is_unassigned = 0 AND github_issue_number IS NULL` in the query, or check `card.is_unassigned` before creating the issue.
**Warning signs:** Issue titles containing `unassigned:order:` in the GitHub repo.

### Pitfall 2: Race between issue creation and note backfill

**What goes wrong:** `sync_card_issues()` creates the issue, then iterates notes to post as comments. If the loop is interrupted (app crash, gh CLI failure), notes may be partially posted on retry without a way to detect what was already sent.
**Why it happens:** `synced_at` is updated per-note after a successful comment post.
**How to avoid:** The `synced_at` column per note is the idempotency guard. On retry, skip notes where `synced_at IS NOT NULL`. This requires a `read_unsynced_notes(card_id)` query that filters by `synced_at IS NULL`.

### Pitfall 3: `PendingEditFlusher` and sync thread both calling `GhIssuesClient`

**What goes wrong:** Two threads concurrently calling `gh` CLI subprocesses. `GhIssuesClient` has no internal mutex — it creates a new `Command` per call, which is thread-safe at the OS level. The temp file path `wit-what-issue-body.txt` is shared, causing file write collisions.
**Why it happens:** Both `create_issue()` and `edit_issue_body()` write to the same temp file path (`std::env::temp_dir().join("wit-what-issue-body.txt")`).
**How to avoid:** Use unique temp file names (e.g., include a UUID or timestamp suffix). Alternative: wrap `GhIssuesClient` in `Arc<Mutex<GhIssuesClient>>` to serialize calls. Given the low call volume, the Mutex approach is simpler and safer.
**Warning signs:** Intermittent "body does not match" bugs or truncated issue bodies during concurrent sync+flush.

### Pitfall 4: `pending_edits` table schema gap for retry tracking

**What goes wrong:** V001's `pending_edits` table has no `retry_count` or `last_attempted_at` columns. Trying to implement backoff without these columns requires either a full row replacement or keeping retry state in memory (lost on restart).
**Why it happens:** V001 defined the table skeleton but left retry fields for the implementation phase.
**How to avoid:** V006 migration MUST add `retry_count INTEGER NOT NULL DEFAULT 0` and `last_attempted_at TEXT` to `pending_edits`. Verify the migration runs before any flusher code touches the table.

### Pitfall 5: `save_note` in `DashboardDataClient` trait only saves to SQLite today

**What goes wrong:** `LiveClient.save_note()` saves to SQLite and returns `Ok(())`. After Phase 18, the GH comment creation must also happen — but the trait's `save_note` signature returns `Result<(), String>` and is called from `EditDispatcher` on whatever thread the dashboard is running on (Slint event thread).
**Why it happens:** GH API calls cannot block the Slint event thread.
**How to avoid:** `save_note` saves to SQLite synchronously (unchanged), then spawns a background thread for the immediate GH attempt, with fallback to `pending_edits` on failure. The trait return value does not change — it reports the SQLite write result only.

### Pitfall 6: Note comment format uses backtick-wrapped tag in Markdown

**What goes wrong:** The `` `ww-note` `` tag format (D-11) contains Markdown backtick syntax. When writing to a temp file for `gh issue comment --body-file`, the raw string must be written as-is (not shell-escaped). The existing `--body-file` pattern handles this correctly already.
**Why it happens:** New developers might try `--body` flag instead of `--body-file`, causing shell interpretation of special characters.
**How to avoid:** Always use `--body-file` with a temp file, as established in `create_issue()` and `edit_issue_body()`.

---

## Code Examples

### V006 Migration

```sql
-- Source: V001__initial_schema.sql (pattern), decisions D-08, D-13, D-17
-- V006: Add github_issue_number to cards, synced_at to notes, retry fields to pending_edits.
ALTER TABLE cards ADD COLUMN github_issue_number INTEGER;
ALTER TABLE notes ADD COLUMN synced_at TEXT;
ALTER TABLE pending_edits ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE pending_edits ADD COLUMN last_attempted_at TEXT;
```

### `format_card_title()` helper

```rust
// Source: D-06, D-04 decisions (HIGH confidence)
pub fn format_card_title(card_id: &str, recipient_name: &str, product_refs: &[CardProductRef],
    product_names: &[String], shopify_order_id: Option<&str>) -> String {
    let first_name = product_names.first().map(|s| s.as_str()).unwrap_or("(no products)");
    let first_serial = product_refs.first().and_then(|r| r.serial_id.as_deref());
    let serial_part = first_serial.map(|s| format!(" {}", s)).unwrap_or_default();
    let extra_count = product_names.len().saturating_sub(1);
    let extra_part = if extra_count > 0 { format!(" (+{})", extra_count) } else { String::new() };
    let order_part = shopify_order_id.map(|id| format!(" [#{}]", id)).unwrap_or_default();
    format!("{} - {}{}{}{}", recipient_name, first_name, serial_part, extra_part, order_part)
}
// Example: "John Doe - Frying Pan FP3067 (+2) [#6012345]"
```

### `create_issue_comment()` — new GhIssuesClient method

```rust
// Source: GhIssuesClient.edit_issue_body() pattern (HIGH confidence)
pub fn create_issue_comment(&self, issue_number: i64, body: &str) -> Result<(), GhIssuesError> {
    let tmp_path = std::env::temp_dir().join(format!("wit-what-comment-{}.txt", issue_number));
    std::fs::write(&tmp_path, body).map_err(|e| {
        GhIssuesError::Transport(format!("Failed to write temp comment file: {}", e))
    })?;
    let number_str = issue_number.to_string();
    let output = Command::new(&self.gh_path)
        .args(["issue", "comment", &number_str, "--repo", &self.repo_slug(),
               "--body-file", &tmp_path.to_string_lossy()])
        .output()
        .map_err(|e| GhIssuesError::Transport(format!("gh exec failed: {}", e)))?;
    let _ = std::fs::remove_file(&tmp_path);
    self.check_output(&output)?;
    Ok(())
}
```

### `edit_issue_title()` — new GhIssuesClient method

```rust
// Source: gh issue edit --title flag (HIGH confidence, consistent with edit_issue_body pattern)
pub fn edit_issue_title(&self, number: i64, title: &str) -> Result<(), GhIssuesError> {
    let number_str = number.to_string();
    let output = Command::new(&self.gh_path)
        .args(["issue", "edit", &number_str, "--repo", &self.repo_slug(), "--title", title])
        .output()
        .map_err(|e| GhIssuesError::Transport(format!("gh exec failed: {}", e)))?;
    self.check_output(&output)?;
    Ok(())
}
```

### `format_note_comment()` — per D-11

```rust
// Source: D-11 decision
pub fn format_note_comment(content: &str) -> String {
    let quoted = content.lines()
        .map(|line| format!("> _{}_", line))
        .collect::<Vec<_>>()
        .join("\n");
    format!("`ww-note`\n\n{}", quoted)
}
```

### SQLite method additions needed on `SqliteStore`

```rust
// set_card_issue_number — targeted update, no full row upsert
pub fn set_card_issue_number(&self, card_id: &str, number: i64) -> Result<(), rusqlite::Error>;

// read_unsynced_notes — for backfill idempotency
pub fn read_unsynced_notes(&self, card_id: &str) -> Result<Vec<(i64, NoteEntry)>, rusqlite::Error>;
// Returns (row_id, NoteEntry) — row_id needed to write synced_at back

// mark_note_synced — set synced_at on a specific note row
pub fn mark_note_synced(&self, note_id: i64, synced_at: &str) -> Result<(), rusqlite::Error>;

// insert_pending_edit — write a new pending edit row
pub fn insert_pending_edit(&self, entity_type: &str, entity_id: &str, edit_type: &str, payload: &str) -> Result<(), rusqlite::Error>;

// read_pending_edits — read all rows ordered by created_at
pub fn read_pending_edits(&self) -> Result<Vec<PendingEditRow>, rusqlite::Error>;

// delete_pending_edit — remove a successfully flushed edit
pub fn delete_pending_edit(&self, id: i64) -> Result<(), rusqlite::Error>;

// increment_pending_edit_retry — update retry_count and last_attempted_at
pub fn increment_pending_edit_retry(&self, id: i64, last_attempted_at: &str) -> Result<(), rusqlite::Error>;
```

---

## State of the Art

| Old Approach | Current Approach | Changed In | Impact |
|--------------|------------------|------------|--------|
| JSON file `pending_edits.json` | SQLite `pending_edits` table (V006) | Phase 18 (D-15) | Retry state survives app restart; no file path management |
| `PendingEditQueue` in `edit_queue.rs` | `PendingEditFlusher` struct in new module | Phase 18 | Thread-safe, SQLite-backed, hybrid immediate+timer strategy |
| `notes` table with no sync tracking | `notes.synced_at` column (V006) | Phase 18 (D-13) | Idempotent backfill; enables future bidirectional sync |
| `cards` table with no GH Issue reference | `cards.github_issue_number` column (V006) | Phase 18 (D-08) | Note save path can look up issue number without a scan |

**Deprecated after Phase 18:**
- `edit_queue.rs`: `PendingEditQueue`, `PendingEdit` enum with `AddItem`/`RemoveItem`/`RenameItem` variants — these do not map to GH operations and should be removed or hollowed out.
- `pending_edits.json` file in `%APPDATA%/WITwhat/` — delete on startup (D-15).

---

## Open Questions

1. **`GhIssuesClient` temp file race condition (Pitfall 3)**
   - What we know: Both `create_issue()` and `edit_issue_body()` use the same fixed temp path.
   - What's unclear: Whether the flusher and sync thread will actually overlap calls in practice (sync is infrequent; flush passes are 60s).
   - Recommendation: Use a timestamp/UUID suffix for all temp files in new methods (`create_issue_comment`, `edit_issue_title`), and patch the existing methods in the same plan wave. Or wrap in `Mutex`. Low urgency — log a warning if it occurs.

2. **`save_note` in `DashboardDataClient` trait vs. direct `LiveClient` call**
   - What we know: `save_note` is a trait method, called from `EditDispatcher`. The dispatcher has no access to the flusher.
   - What's unclear: Whether the immediate GH attempt should happen inside `save_note` (spawning a thread) or if the note save triggers an event that the flusher picks up.
   - Recommendation: Inside `LiveClient.save_note()`, after SQLite write, spawn a fire-and-forget thread for the immediate attempt (same pattern as `update_recipient_field`). The flusher handles retry. This avoids trait signature changes.

3. **`CardRow` does not have `github_issue_number` yet**
   - What we know: The V006 migration adds the column. `CardRow` struct in `sqlite.rs` must be updated to include `pub github_issue_number: Option<i64>`.
   - What's unclear: Whether `upsert_card` should write this field (risky — upsert could clear it) or if `set_card_issue_number` is always a separate targeted UPDATE.
   - Recommendation: Targeted UPDATE only. Never include `github_issue_number` in the upsert path — it would be overwritten to NULL on every sync cycle.

---

## Environment Availability

Step 2.6: SKIPPED — Phase 18 is pure code changes using tools already verified present (gh CLI, SQLite, Rust toolchain). No new external dependencies.

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[test]` + `rusqlite` in-memory |
| Config file | Cargo.toml `[dev-dependencies]` (no separate config file) |
| Quick run command | `cargo test -p app --lib 2>&1` |
| Full suite command | `cargo test --workspace 2>&1` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CLOUD-01 | `sync_card_issues()` creates ww-card issue for cards with no github_issue_number | unit | `cargo test -p app --lib sync_card_issues 2>&1` | ❌ Wave 0 |
| CLOUD-01 | Unassigned cards are skipped by issue creation | unit | `cargo test -p app --lib sync_card_issues_skips_unassigned 2>&1` | ❌ Wave 0 |
| CLOUD-02 | `PendingEditFlusher` flushes `UpdateCardBody` edit via `edit_issue_body` | unit | `cargo test -p app --lib flusher_flushes_update_card_body 2>&1` | ❌ Wave 0 |
| CLOUD-02 | Backoff skips rows whose `last_attempted_at + backoff(retry_count) > now` | unit | `cargo test -p app --lib flusher_respects_backoff 2>&1` | ❌ Wave 0 |
| CLOUD-05 | `format_note_comment()` produces correct `ww-note` format | unit | `cargo test -p integrations -- format_note_comment 2>&1` | ❌ Wave 0 |
| CLOUD-05 | `save_note()` inserts into `pending_edits` when no github_issue_number set | unit | `cargo test -p app --lib save_note_queues_when_no_issue 2>&1` | ❌ Wave 0 |
| CLOUD-05 | `read_unsynced_notes()` returns only notes where `synced_at IS NULL` | unit | `cargo test -p service -- read_unsynced_notes 2>&1` | ❌ Wave 0 |
| V006 migration | `github_issue_number` column present after migration | unit | `cargo test -p service -- v006_migration 2>&1` | ❌ Wave 0 |
| V006 migration | `synced_at` column on notes present after migration | unit (above) | (same test) | ❌ Wave 0 |

### Sampling Rate

- **Per task commit:** `cargo test -p app --lib 2>&1`
- **Per wave merge:** `cargo test --workspace 2>&1`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `crates/app/src/dashboard/pending_edit_flusher.rs` — new module, covers CLOUD-02 flusher tests
- [ ] `crates/integrations/src/github/issues_client.rs` — add tests for `format_note_comment`, `parse_card_body`, `format_card_body` (pattern from existing `parse_product_body` tests)
- [ ] `crates/service/src/db/sqlite.rs` — add tests for V006 schema columns, `read_unsynced_notes`, `mark_note_synced`, `insert_pending_edit`, `read_pending_edits`, `delete_pending_edit`, `increment_pending_edit_retry`

---

## Project Constraints (from CLAUDE.md)

- **Windows-only terminal commands** — no Unix-specific assumptions in shell scripts.
- **No `jq` command** — `gh api --jq` is allowed (it is a gh CLI flag, not the system `jq` binary). All JSON parsing uses Rust `serde_json`.
- **DATA-FLOW.md update obligation** — any new fields (e.g., `github_issue_number` on cards, `synced_at` on notes) must be documented in DATA-FLOW.md in the same commit.
- **RULE-01** — `github_profile_url` must not appear anywhere in Phase 18 code.
- **RULE-03** — All reads from SQLite only. GH Issues write-back is async; UI always reads from SQLite.
- **RULE-05** — GH Issues are the cloud of record. `ww-card` issues are authoritative; SQLite mirrors them locally.
- **RULE-06** — No new data fields without a source in DATA-FLOW.md. `github_issue_number` source: GH Issue number returned by `create_issue()`. `synced_at` source: app clock at time of successful comment post.
- **BUGSWEEPER gate** — any debug instrumentation must remain behind `#[cfg(feature = "bugsweeper")]`.
- **No plaintext secrets** — no tokens in config files (not relevant to Phase 18, but included for completeness).

---

## Sources

### Primary (HIGH confidence)

- `crates/integrations/src/github/issues_client.rs` — full source read; GhIssuesClient API, ProductIssueBody pattern, create_issue/edit_issue_body implementation
- `crates/app/src/live_client.rs` — full source read (relevant sections); run_sync_cycle, sync_products pattern, save_note implementation, fire-and-forget pattern
- `crates/service/src/db/sqlite.rs` — full source read; SqliteStore API, save_note, read_notes, upsert patterns
- `crates/service/src/db/migrations/V001__initial_schema.sql` — full source read; pending_edits table schema (entity_type, entity_id, edit_type, payload, created_at)
- `crates/app/src/dashboard/edit_queue.rs` — full source read; current PendingEditQueue (JSON-file backed) to be replaced
- `.planning/phases/18-gh-issues-write-back-notes-and-card-cloud-storage/18-CONTEXT.md` — all decisions D-01 through D-18
- `.planning/DATA-FLOW.md` — RULE-01 through RULE-08, Layer 2 Cards field table

### Secondary (MEDIUM confidence)

- `gh issue comment --body-file` command: inferred from `gh issue create --body-file` and `gh issue edit --body-file` patterns already in codebase; consistent with gh CLI design.
- `gh issue edit --title` flag: inferred from gh CLI consistent flag naming; not directly observed in codebase but follows established pattern.

### Tertiary (LOW confidence)

- None — all critical claims verified from project source.

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all dependencies already in project, no new libraries
- Architecture patterns: HIGH — directly derived from Phase 17 `sync_products()` and `GhIssuesClient` source
- Pitfalls: HIGH — temp file collision is observable from source; others are structural analysis
- New GH CLI methods (`create_issue_comment`, `edit_issue_title`): MEDIUM — pattern is clear, exact flag names inferred from gh CLI design conventions

**Research date:** 2026-03-27
**Valid until:** 2026-05-27 (stable domain — Rust stdlib, SQLite, gh CLI are very stable)
