# Phase 20: Offline Mode Hardening - Research

**Researched:** 2026-04-10
**Domain:** Offline-first write architecture, SQLite pending edit queue, Slint animation
**Confidence:** HIGH

## Summary

Phase 20 is primarily a wiring-and-extension phase, not a greenfield build. The infrastructure
already exists: a `pending_edits` SQLite table, a `PendingEditFlusher` background thread with
exponential backoff, a `connection-status` Slint property wired to sync success/failure, and a
`pending-edit-count` Slint property defined but not yet driven from Rust. The phase work is:
(1) ensuring all write action callsites use the local-first + queue pattern uniformly (most
already do, some edge cases don't), (2) adding conflict avoidance in `run_sync_cycle` so sync
never overwrites a pending edit, (3) driving `pending-edit-count` from the flusher thread so
the breathing animation has a signal to react to, and (4) implementing the breathing green dot
animation in `dashboard.slint` using `animation-tick()`.

The biggest implementation risk is the conflict avoidance logic (D-07). The upsert loop in
`run_sync_cycle` at Step 4 (lines ~989-1021 of `live_client.rs`) already handles preservation
of locally-assigned serial unit refs. Extending it to also skip-on-pending-edit requires a
`count_pending_edits_for_entity()` method on `SqliteStore` (currently absent) and a decision
on granularity: field-level vs. entity-level conflict avoidance.

The breathing animation uses the idiomatic Slint `animation-tick()` + `Math.sin()` pattern
(confirmed in Slint docs and GitHub discussions). The `iterate-count: -1` approach has known
issues with mid-cycle restarts and should not be used.

**Primary recommendation:** Implement conflict avoidance at entity level (if any pending edit
exists for a card_id, skip all Shopify-sourced field updates for that card in the upsert pass).
Field-level granularity adds complexity with minimal practical benefit given the single-user
nature of the app.

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** The existing status dot (red/"Disconnected" text) is sufficient for offline
  indication. No additional banner, overlay, or dimmed UI needed.
- **D-02:** Binary online/offline model — no per-service status breakdown. The `connection-status == 4`
  ("Connected, no Shopify") state is unrelated and stays as-is.
- **D-03:** All writes go to local SQLite first and update the UI immediately. Every change is
  also enqueued as a pending edit for remote sync. The app's local database is the UI's source
  of truth at all times.
- **D-04:** Every write action — notes, card body, card title, serial state transitions,
  item/product changes — uses the local-first + queue pattern uniformly. No exceptions.
- **D-05:** While pending unflushed edits exist, the green status indicator circle "breathes"
  (gradual lighter-to-darker-to-lighter loop animation). When the queue is empty, it stays
  solid green. This is the only visual feedback for pending edits — no per-card sync icons,
  no pending count in the status bar.
- **D-06:** The flusher runs continuously on its own short interval, independent of the sync
  cycle timer. They are decoupled — flusher does not block sync, sync does not block flusher.
- **D-07:** When sync pulls remote data and a pending edit exists for the same field on the same
  card, the local pending edit wins. Sync writes remote data to SQLite but skips any field that
  has a pending edit. The local version is authoritative until the edit is flushed and confirmed.
- **D-08:** Failed flushes retry with the existing exponential backoff strategy. After a max retry
  count (e.g., 5 attempts), the edit is dropped and a warning is logged. The local SQLite state
  remains as-is. No user notification for dropped edits.
- **D-09:** Post-sync-failure detection is sufficient. The `connection-status` property updates
  after each sync cycle attempt. No additional health pings, probes, or OS-level network listeners.

### Claude's Discretion

- Exact breathing animation timing (pulse frequency, easing curve)
- Max retry count before dropping a failed edit (suggested: 5 — currently hardcoded as 15)
- Flusher interval when running continuously (currently 60s — may adjust)
- Implementation approach for field-level pending edit conflict avoidance during sync

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within phase scope.
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| OFFLINE-01 | App detects connectivity loss and continues functioning with cached SQLite data | `connection-status` already set to 3 on sync failure. SQLite data always available. Gap: no explicit "offline mode" divergence from normal behavior — this is already the default |
| OFFLINE-02 | Edits made while offline are queued and flushed when connectivity returns | `PendingEditFlusher` + `pending_edits` table partially implements this. Gap: not all write callsites use the queue pattern (serial state transitions bypass it in some paths); `pending-edit-count` not driven from Rust |
| OFFLINE-03 | Flush-then-sync ordering prevents sync from overwriting pending edits | Currently unimplemented. Gap is in `run_sync_cycle` Step 4 upsert loop — no conflict check against `pending_edits` table. New `SqliteStore::has_pending_edits_for_entity()` method needed |
</phase_requirements>

---

## Standard Stack

### Core (already in project)

| Library | Version | Purpose | Note |
|---------|---------|---------|------|
| rusqlite | 0.32 (bundled) | SQLite read/write, pending_edits table | [VERIFIED: Cargo.toml] |
| slint | project version | Breathing animation via `animation-tick()` | [VERIFIED: codebase] |
| std::thread | stdlib | Background flusher thread (already spawned) | [VERIFIED: pending_edit_flusher.rs] |
| serde_json | project version | Pending edit payload serialization | [VERIFIED: pending_edit_flusher.rs] |

No new dependencies are required for Phase 20. All infrastructure is already in the project.

**Version verification:** All packages already present in Cargo.toml — no registry check needed.
[VERIFIED: Cargo.toml via codebase inspection]

## Architecture Patterns

### Existing Architecture (What Already Works)

```
User action (UI)
    │
    ▼
LiveClient write method (save_note / update_unit_state / etc.)
    │
    ├─► SQLite write (immediate, on calling thread or background)
    │
    └─► insert_pending_edit() → pending_edits table
             │
             ▼
    PendingEditFlusher (background thread, 60s interval)
             │
             ├─ backoff check (retry_count, last_attempted_at)
             │
             ├─ flush_one() → GhIssuesClient call
             │
             ├─ SUCCESS → delete_pending_edit(id)
             └─ FAILURE → increment_pending_edit_retry(id, now)
```

The sync loop (`run_sync_cycle`) runs on a separate background thread every 5 minutes and calls
`SyncUpdateCallback` on the Slint event loop thread to set `connection_status` (2=connected,
3=disconnected) and update card data.

### Pattern 1: Local-First Write (Established, Needs Extension to All Callsites)

**What:** Write to SQLite immediately, enqueue pending edit for GH sync, update UI from SQLite.
**When to use:** ALL write actions without exception (D-04).

```rust
// Source: crates/app/src/live_client.rs — save_note() pattern
// Step 1: Write to SQLite
self.store.save_note(package_id, &entry)
    .map_err(|e| format!("SQLite note write error: {:?}", e))?;

// Step 2: Queue for GH sync (fire-and-forget background thread)
let _ = store_clone.insert_pending_edit("card", &card_id, "SaveNote", &payload);
```

**Gap:** `queue_unit_state_edit()` in `pending_edit_flusher.rs` already exists for serial
transitions called from the sync pipeline. But user-initiated state modal actions
(`on_state_modal_confirmed`) call `store.update_unit_state()` but then separately call
`queue_unit_state_edit()`. The ordering is correct there. The pattern is complete for serial
transitions. Verify no path directly calls a GH API without also queuing.

### Pattern 2: Conflict Avoidance During Sync (New — D-07)

**What:** Before writing a synced field to SQLite, check if a pending edit exists for that
entity. If so, skip the sync write for that card.

**When to use:** In `run_sync_cycle` Step 4 upsert loop, before calling `store.upsert_card(&row)`.

**Implementation approach — entity-level (RECOMMENDED):**

```rust
// Source: New method needed on SqliteStore
// In run_sync_cycle Step 4 upsert loop:
if store.has_pending_edits_for_entity("card", &snap.recipient_id).unwrap_or(false) {
    eprintln!("[sync] Skipping upsert for {} — pending edits in flight", snap.recipient_id);
    continue;
}
if let Err(e) = store.upsert_card(&row) {
    eprintln!("[sync] SQLite write error for card {}: {:?}", snap.recipient_id, e);
}
```

**New SqliteStore method needed:**

```rust
// Source: needs to be added to crates/service/src/db/sqlite.rs
pub fn has_pending_edits_for_entity(
    &self,
    entity_type: &str,
    entity_id: &str,
) -> Result<bool, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    let count: i64 = conn.query_row(
        "SELECT COUNT(*) FROM pending_edits
         WHERE entity_type = ?1 AND entity_id = ?2 AND retry_count < ?3",
        params![entity_type, entity_id, MAX_RETRIES],
        |row| row.get(0),
    )?;
    Ok(count > 0)
}
```

Note: `MAX_RETRIES` in the flusher is 15. Dormant edits (retry_count >= MAX_RETRIES) should
NOT block sync — they are abandoned. Only active edits (retry_count < MAX_RETRIES) trigger the
skip. [ASSUMED: exact MAX_RETRIES threshold to use in this query; currently 15 in flusher]

### Pattern 3: Driving `pending-edit-count` from Rust (New — D-05)

**What:** After each flush pass and after each sync callback, read the count of active pending
edits from SQLite and set `DashboardWindow.pending-edit-count` via `invoke_from_event_loop`.

**Current state:** `pending-edit-count` is an `in property <int>` defined at line 101 of
`dashboard.slint`. It is NEVER SET from Rust — only readable by BUGSWEEPER. The
`SyncUpdateCallback` sets `connection_status` but ignores `pending_edit_count`.

**Two update triggers needed:**

1. After each `run_flush_pass()` in the flusher — post the new count via `invoke_from_event_loop`.
2. After each `SyncUpdateCallback` fires — read count from store and set on window.

**New SqliteStore method needed:**

```rust
// Source: needs to be added to crates/service/src/db/sqlite.rs
pub fn count_active_pending_edits(&self) -> Result<i64, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    conn.query_row(
        "SELECT COUNT(*) FROM pending_edits WHERE retry_count < ?1",
        params![MAX_RETRIES],  // exclude dormant edits
        |row| row.get(0),
    )
}
```

**Flusher needs a Slint weak handle:** `start_flusher()` currently takes only `Arc<SqliteStore>`
and `Arc<GhIssuesClient>`. To post `invoke_from_event_loop` updates, it also needs a
`slint::Weak<DashboardWindow>` — or the count update can be handled differently (see Pitfalls).

### Pattern 4: Breathing Animation (Slint — D-05)

**What:** When `pending-edit-count > 0`, the green status dot pulses (lighter-to-darker loop).
When queue is empty, solid green. Implemented in `dashboard.slint`.

**Idiomatic Slint approach:** Use `animation-tick()` + `Math.sin()` for a smooth continuous
loop. Avoid `iteration-count: -1` — known issues with mid-cycle restarts.
[VERIFIED: Slint docs at docs.slint.dev/latest/docs/slint/guide/language/coding/animation/]
[CITED: https://github.com/slint-ui/slint/issues/3494 — project member recommends animation-tick()]

```slint
// Source: Pattern from Slint GitHub discussions (animation-tick + Math.sin)
// Replaces the static green dot Rectangle (lines 451-461 of dashboard.slint)
Rectangle {
    width: 8px;
    height: 8px;
    border-radius: 4px;
    y: 5px;
    // Breathing: sin wave drives opacity between 0.55 and 1.0
    // Only active when pending-edit-count > 0 AND connected (status == 2)
    property <float> pulse-phase: root.connection-status == 2 && root.pending-edit-count > 0
        ? 0.5 + 0.5 * Math.sin(animation-tick() / 1200ms * 1turn)
        : 1.0;
    opacity: pulse-phase;
    background: root.connection-status == 0 ? #666666
              : root.connection-status == 1 ? #f0c040
              : root.connection-status == 2 ? Colors.success
              : root.connection-status == 4 ? #f0c040
              : Colors.error;
}
```

The `animation-tick()` function returns a continuously incrementing Duration driven by the
Slint render loop. Dividing by the desired period (1200ms) and multiplying by `1turn` converts
it to a full oscillation cycle. `0.5 + 0.5 * Math.sin(...)` maps the sin output (-1..1) to
the range (0..1) — but typically you want opacity between 0.55..1.0 to avoid too-dark pulse.
[VERIFIED: Slint GitHub discussion — Math.sin pattern: `red.mix(green, 0.5 + 0.5 * Math.sin(animation-tick()/3s * 1turn))`]

**Timing recommendation (Claude's Discretion):** 1200ms period, opacity 0.5..1.0. Gives a
slow, calm pulse (not alarming). Adjust to taste.

### Existing Status Dot Location

The green dot Rectangle is at `dashboard.slint` lines 451-461. It's inside a `HorizontalLayout`
at lines 420-472. The existing "N edits pending" text label at lines 411-417 can be REMOVED
once the breathing dot replaces it as the sole visual indicator (per D-05).

### Anti-Patterns to Avoid

- **Direct GH API calls without queuing:** Any path that calls `gh_client.edit_issue_body()`,
  `gh_client.create_issue_comment()`, or `gh_client.edit_issue_title()` directly (not via
  the flusher queue) is a D-04 violation. Verify all callsites use queue-first.
- **`iteration-count: -1` in Slint animation:** Known mid-cycle restart issues. Use
  `animation-tick()` instead.
- **Nesting Mutex locks in SqliteStore:** The `has_pending_edits_for_entity()` and
  `count_active_pending_edits()` methods must lock the Mutex once per call — never call
  one SqliteStore method from inside another locked context. [VERIFIED: SQLITE_TIPS.md pattern]
- **Passing `slint::Weak<DashboardWindow>` into the flusher directly:** Weak is `Send` but
  the flusher loop is long-running. Prefer passing count updates through the existing
  `SyncUpdateCallback` mechanism or a dedicated `Arc<AtomicI64>` counter.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Looping animation | Custom timer + state toggle | `animation-tick()` in Slint | Built-in, no timer overhead, stops automatically when expression constant |
| Offline detection | OS network API, ping thread | Post-sync-failure `connection_status = 3` | Already wired in `live_client.rs`; `LiveClient` is offline-first by design |
| GH retry logic | Custom retry loop | Existing `PendingEditFlusher` with exponential backoff | Already handles retry_count, last_attempted_at, and backoff_secs() |
| Edit queue persistence | In-memory Vec or JSON file | `pending_edits` SQLite table | Already implemented; survives app restart |

## Common Pitfalls

### Pitfall 1: `set_pending_edit_count` Threading

**What goes wrong:** `DashboardWindow` is `!Send`. Calling `w.set_pending_edit_count()` from
the flusher background thread will panic or deadlock.
**Why it happens:** Slint's window type is not thread-safe — must be accessed on the event loop
thread.
**How to avoid:** Use `slint::invoke_from_event_loop(move || { ... w.set_pending_edit_count(n); })`
from the flusher thread. The `Weak<DashboardWindow>` must be passed into `start_flusher()`.
Alternatively, use an `Arc<AtomicI64>` counter updated by the flusher and read by the
`SyncUpdateCallback` — but this delays the display by one sync cycle.
**Warning signs:** Compile error "the trait `Send` is not implemented for `DashboardWindow`"
or runtime panics in the flusher thread.

### Pitfall 2: Conflict Avoidance Blocks All Sync Updates

**What goes wrong:** If `has_pending_edits_for_entity()` checks dormant edits (retry_count >=
MAX_RETRIES), a permanently-failed edit would block sync updates for that card forever.
**Why it happens:** Dormant edits stay in the table — they are not deleted.
**How to avoid:** Filter by `retry_count < MAX_RETRIES` in the SQL query. Dormant edits should
NOT participate in conflict avoidance.
**Warning signs:** Cards stop receiving sync updates after repeated flush failures.

### Pitfall 3: `pending-edit-count` Text vs. Breathing Dot Conflict

**What goes wrong:** The existing `if root.pending-edit-count > 0 : Text { "N edits pending" }`
at lines 411-417 of `dashboard.slint` conflicts with D-05's "breathing dot is the only signal."
If both are left in place, users see text AND animation.
**Why it happens:** The text widget predates Phase 20.
**How to avoid:** Remove or gate the text widget during Phase 20. Per D-05 it should be removed.
**Warning signs:** Two indicators visible simultaneously when pending edits exist.

### Pitfall 4: SQLite_TIPS — DELETE + re-INSERT wipes metadata

**What goes wrong:** Adding a new `notes` upsert that does DELETE + re-INSERT will wipe
`synced_at` column values on existing note rows.
**Why it happens:** The `upsert_card` method uses a delete-then-insert approach for notes
(see SQLITE_TIPS.md).
**How to avoid:** The `pending_edits` table uses INSERT + DELETE (not upsert), so this specific
pitfall doesn't apply. But if any Phase 20 task modifies note saving, apply the diff-based
approach from SQLITE_TIPS.md. [VERIFIED: SQLITE_TIPS.md]

### Pitfall 5: animation-tick() Always Runs (CPU/GPU Cost)

**What goes wrong:** `animation-tick()` causes Slint to continuously re-render the frame, even
when idle with no pending edits, if the expression always references it.
**Why it happens:** The expression `0.5 + 0.5 * Math.sin(animation-tick() / 1200ms * 1turn)`
always changes. Slint won't optimize away the re-render even when `pending-edit-count == 0`.
**How to avoid:** Gate the animation with a ternary: only use `animation-tick()` when
`pending-edit-count > 0`. When count is 0, return a constant `1.0`. Slint optimizes constant
expressions and stops re-rendering.

```slint
// CORRECT: animation-tick() only evaluated when active
property <float> pulse-phase: root.pending-edit-count > 0
    ? 0.5 + 0.5 * Math.sin(animation-tick() / 1200ms * 1turn)
    : 1.0;
```

**Warning signs:** App uses noticeably more CPU when idle (no pending edits).

## Code Examples

### SqliteStore: New Methods Needed

```rust
// Source: pattern follows existing pending_edits methods in sqlite.rs
// Add to crates/service/src/db/sqlite.rs

/// Count active (non-dormant) pending edits — for UI indicator.
pub fn count_active_pending_edits(&self) -> Result<i64, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    conn.query_row(
        "SELECT COUNT(*) FROM pending_edits WHERE retry_count < 15",
        [],
        |row| row.get(0),
    )
}

/// Check if any active pending edits exist for an entity — for sync conflict avoidance.
pub fn has_pending_edits_for_entity(
    &self,
    entity_type: &str,
    entity_id: &str,
) -> Result<bool, rusqlite::Error> {
    let conn = self.conn.lock().unwrap();
    let count: i64 = conn.query_row(
        "SELECT COUNT(*) FROM pending_edits
         WHERE entity_type = ?1 AND entity_id = ?2 AND retry_count < 15",
        params![entity_type, entity_id],
        |row| row.get(0),
    )?;
    Ok(count > 0)
}
```

### Flusher: Post Count Update via invoke_from_event_loop

```rust
// Source: pattern from existing invoke_from_event_loop usage in live_client.rs
// After run_flush_pass() in pending_edit_flusher.rs start_flusher()

let window_weak = /* Weak<DashboardWindow> passed to start_flusher */;
let store_ref = Arc::clone(&store);
let _ = slint::invoke_from_event_loop(move || {
    if let Some(w) = window_weak.upgrade() {
        let count = store_ref.count_active_pending_edits().unwrap_or(0);
        w.set_pending_edit_count(count as i32);
    }
});
```

### SyncCallback: Include Count Update

```rust
// Source: pattern from on_sync callback in main.rs lines ~2392-2419
// Add inside the SyncUpdateCallback closure after set_connection_status:

let pending_count = store_for_pending.count_active_pending_edits().unwrap_or(0);
w.set_pending_edit_count(pending_count as i32);
```

### run_sync_cycle: Conflict Avoidance Insert Point

```rust
// Source: live_client.rs lines ~989-1021 (Step 4 upsert loop)
// Replace the current upsert_card call with:

for snap in &matched {
    let mut row = snapshot_to_card_row(snap);
    // ... existing serial ref preservation logic ...

    // D-07: Skip upsert if pending edits exist for this card
    if store.has_pending_edits_for_entity("card", &snap.recipient_id).unwrap_or(false) {
        eprintln!("[sync] Skipping card {} — pending edits in flight", snap.recipient_id);
        continue;
    }

    if let Err(e) = store.upsert_card(&row) {
        eprintln!("[sync] SQLite write error for card {}: {:?}", snap.recipient_id, e);
    }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| JSON file edit queue | SQLite `pending_edits` table | Phase 18 Plan 03 | Survives restart; consistent with WAL strategy |
| Fire-and-forget GH writes (no queue) | Queue-then-flush via PendingEditFlusher | Phase 18 | Retry with backoff; offline tolerant |

**What is NOT yet done (Phase 20 gaps):**
- `pending-edit-count` is defined in Slint but never SET from Rust
- Breathing animation does not exist yet — only static dot color
- Conflict avoidance (D-07) is not implemented in `run_sync_cycle`
- The "N edits pending" text (dashboard.slint:411-417) exists but should be replaced by the dot animation per D-05

## Write Action Callsite Audit (D-04 Compliance)

All write actions must use local-first + queue. Verified from source:

| Action | Callsite | SQLite write? | Queue? | Status |
|--------|----------|--------------|--------|--------|
| SaveNote | `live_client.rs` `save_note()` | YES | YES (via insert_pending_edit) | Compliant |
| UpdateCardBody | `live_client.rs` `detect_card_changes()` | YES (source) | YES | Compliant |
| UpdateCardTitle | `live_client.rs` `detect_card_changes()` | YES (source) | YES | Compliant |
| UpdateUnitState (sync-triggered) | `live_client.rs` `sync_return_states()` | YES | YES via `queue_unit_state_edit()` | Compliant |
| UpdateUnitState (user action) | `main.rs` `on_state_modal_confirmed` | YES `update_unit_state()` | YES `queue_unit_state_edit()` | Compliant |
| Unit assignment (`try_assign_unit`) | `main.rs` `on_lookup_unit_selected` | YES | YES `queue_unit_state_edit()` | Compliant |
| Unit force-reassign | `main.rs` `on_lookup_unit_force_reassign` | YES | YES `queue_unit_state_edit()` | Compliant |
| Vision Rx write-back | `live_client.rs` `update_recipient_field()` | NO (GH direct) | NO | NOT compliant [ASSUMED: verify in Phase planning] |
| Discord username write-back | Separate background thread, GH project | NO (GH direct) | NO | NOT compliant [ASSUMED: verify] |

Note: Vision Rx and Discord username write-backs are GH Project mutations (not GH Issues) and
use a different path (`GhCliProjectClient`, not `GhIssuesClient`). D-04 says "all write actions"
should use the local-first + queue pattern. Whether this applies to GH Project field writes
(not GH Issues write-backs) is a planning decision. These are likely out of scope for Phase 20
given the CONTEXT.md scope focuses on notes, card body, card title, serial state transitions,
item/product changes.

## Runtime State Inventory

Step 2.5 SKIPPED — this is not a rename/refactor/migration phase.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Rust/cargo | Build | YES (Windows) | detected in project | — |
| SQLite (bundled) | rusqlite bundled feature | YES | compile-time | — |
| gh CLI | GhIssuesClient flush | YES (assumed — existing phases use it) | — | Flusher degrades gracefully if gh absent |

[ASSUMED: gh CLI is available on the developer machine — all prior phases depend on it]

All dependencies already satisfied — no new installs required.

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Rust built-in `#[test]` + rusqlite in-memory |
| Config file | none — inline `#[cfg(test)]` modules |
| Quick run command | `cargo test --package app -- pending` |
| Full suite command | `cargo test --package app && cargo test --package service` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| OFFLINE-01 | Cached SQLite data readable when `connection_status == 3` | unit | `cargo test --package app -- fetch_card_snapshots` | Partial (fetch_card_snapshots tested in live_client tests) |
| OFFLINE-02 | `insert_pending_edit` called for all write types | unit | `cargo test --package app -- pending_edit` | Partial (backoff tests exist in flusher) |
| OFFLINE-02 | `pending-edit-count` set correctly after write | unit | `cargo test --package service -- count_active_pending` | NO — Wave 0 gap |
| OFFLINE-03 | Sync skips card with active pending edit | unit | `cargo test --package app -- conflict_avoidance` | NO — Wave 0 gap |
| OFFLINE-03 | `has_pending_edits_for_entity` returns true when active | unit | `cargo test --package service -- has_pending_edits` | NO — Wave 0 gap |

### Sampling Rate

- **Per task commit:** `cargo test --package app && cargo test --package service`
- **Per wave merge:** Same
- **Phase gate:** Full suite green before `/gsd-verify-work`

### Wave 0 Gaps

- [ ] `crates/service/src/db/sqlite.rs` — test for `count_active_pending_edits()` method
- [ ] `crates/service/src/db/sqlite.rs` — test for `has_pending_edits_for_entity()` method
- [ ] `crates/app/src/live_client.rs` — test for conflict avoidance: insert pending edit + run partial sync + verify upsert skipped
- [ ] `crates/app/src/dashboard/pending_edit_flusher.rs` — test that `pending-edit-count` is set after flush pass (requires mocking `invoke_from_event_loop`)

Note: The last gap (mocking Slint event loop) may be marked manual-only if `invoke_from_event_loop` is not easily testable without a Slint window.

## Security Domain

This phase does not introduce authentication, session management, access control, cryptography,
or new input surfaces. All data flows through existing SQLite + GH Issues paths. ASVS categories
V2/V3/V4/V6 do not apply. V5 (input validation) is inherited from existing callsites — no new
user input paths are introduced.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Vision Rx and Discord username write-backs are out of scope for D-04 compliance | Write Action Audit | If in scope, two additional queue mechanisms needed |
| A2 | gh CLI is available on the developer machine | Environment Availability | No build impact — flusher degrades gracefully |
| A3 | Entity-level conflict avoidance (skip entire card) is sufficient vs. field-level | Architecture Patterns | Field-level would require per-field pending edit tracking in SQLite schema |
| A4 | `pending-edit-count` should count only non-dormant edits (retry_count < 15) | Architecture Patterns | If dormant edits are counted, indicator never clears for failed edits |

## Open Questions (RESOLVED)

1. **Flusher receives Weak<DashboardWindow>?**
   - What we know: `start_flusher()` currently takes only `Arc<SqliteStore>` and `Arc<GhIssuesClient>`
   - What's unclear: Is it architecturally clean to pass `Weak<DashboardWindow>` into the flusher,
     or should `pending-edit-count` updates happen only in the `SyncUpdateCallback`?
   - Recommendation: Add `Option<slint::Weak<DashboardWindow>>` to `start_flusher()`. This
     matches the existing pattern in `live_client.rs` where `window_weak` is captured in closures.
     The count update fires after each flush pass (60s), keeping it fresh without sync dependency.

2. **Remove or keep the "N edits pending" text?**
   - What we know: D-05 says "no pending count in the status bar." The text at lines 411-417
     already shows this count.
   - What's unclear: Whether the planner should remove it entirely or just not update it.
   - Recommendation: Remove the text widget entirely in the same wave as the breathing dot lands.

3. **Conflict avoidance for `serial_instance` entity_type?**
   - What we know: `queue_unit_state_edit()` uses entity_type = "serial_instance", entity_id = serial_id.
   - What's unclear: Should `run_sync_cycle`'s `upsert_product_unit` also check for pending
     unit state edits before writing?
   - Recommendation: Yes — apply the same `has_pending_edits_for_entity("serial_instance", serial_id)`
     check before `store.upsert_product_unit(&unit_row)` in `sync_products()`.

## Sources

### Primary (HIGH confidence)
- `crates/app/src/dashboard/pending_edit_flusher.rs` — full flusher implementation, edit types, backoff
- `crates/service/src/db/sqlite.rs` — PendingEditRow, insert_pending_edit, read_pending_edits, delete_pending_edit
- `crates/app/src/live_client.rs` — run_sync_cycle, save_note, SyncUpdateCallback, connection_status wiring
- `crates/app/ui/dashboard.slint` lines 101-104, 411-472 — pending-edit-count property, status dot
- `.planning/phases/20-offline-mode-hardening/20-CONTEXT.md` — locked decisions D-01 through D-09
- `code_tips/SQLITE_TIPS.md` — mutex lock discipline, INSERT OR REPLACE pitfall
- [Slint animation docs](https://docs.slint.dev/latest/docs/slint/guide/language/coding/animation/) — animation-tick() pattern

### Secondary (MEDIUM confidence)
- [Slint GitHub issue #3494](https://github.com/slint-ui/slint/issues/3494) — project member recommends animation-tick() over iteration-count
- Slint GitHub discussion — `Math.sin(animation-tick()/3s * 1turn)` color mixing example

### Tertiary (LOW confidence)
- None

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies, all verified in codebase
- Architecture: HIGH — existing code read directly, gaps clearly identified
- Pitfalls: HIGH — derived from direct code inspection and SQLITE_TIPS.md
- Slint animation: MEDIUM-HIGH — verified via official docs and GitHub issue

**Research date:** 2026-04-10
**Valid until:** 2026-05-10 (stable infrastructure; Slint API stable)
