# Sprint 8: Desktop Parity & Persistence

> **Priority**: 2 | **Owner**: Client + Backend | **Complexity**: Medium
> **Prerequisite**: Sprint 7

---

## Validation Status (2025-12-28)

### ✅ COMPLETE

| Component | Location | Status | Finding |
|---|---|---|---|
| Desktop audio input test | `client/src/hooks/use-audio-devices.ts:206-226` | ✅ | Wired to `start_input_test` Tauri command |
| Desktop audio output test | `client/src/hooks/use-audio-devices.ts:303-316` | ✅ | Wired to `start_output_test` Tauri command |
| Tauri audio test commands | `client/src-tauri/src/commands/audio.rs:247-393` | ✅ | `start_input_test`, `stop_input_test`, `start_output_test`, `stop_output_test` |
| Entity store | `client/src/lib/entity-store.ts` | ✅ | Full store with `updateEntityWithPersist`, `deleteEntityWithPersist` |
| Entity extraction RPC | `src/noteflow/grpc/_mixins/entities.py` | ✅ | `ExtractEntities()` RPC complete |
| Entity pin/unpin | `src/noteflow/grpc/_mixins/entities.py` | ✅ | **Done in Sprint 4** — `PinEntity` RPC + `update_pinned()` repo |
| Entity update (text/category) | `src/noteflow/grpc/_mixins/entities.py:89-128` | ✅ | `UpdateEntity` RPC with text/category mutation |
| Entity delete (single) | `src/noteflow/grpc/_mixins/entities.py:132-166` | ✅ | `DeleteEntity` RPC + `entity_repo.delete()` |
| Entity panel wiring | `client/src/components/entity-management-panel.tsx` | ✅ | Calls backend on edit/delete with error handling |

> **Note**: Sprint 4 (NER Extraction) implemented `PinEntity` RPC, `update_pinned()` repository method, and full entity persistence. This sprint completed: audio testing + text/category update + single-entity delete + frontend wiring.

---

## Objective

Bring desktop-only features to parity with browser mode (audio device testing) and make entity edits durable across sessions via backend persistence.

---

## Current State Analysis

### Audio Device Testing

**Browser Mode** ✅ Complete (`use-audio-devices.ts:130-200`):
- Input test uses Web Audio API `AnalyserNode` for level visualization
- Output test generates sine wave tone via `OscillatorNode`
- Device enumeration via `navigator.mediaDevices`

**Desktop Mode** ✅ Complete (`use-audio-devices.ts:206-316`):
- Input test invokes `start_input_test` Tauri command, receives `audio_test_level` events
- Output test invokes `start_output_test` Tauri command, plays 440Hz sine wave for 2 seconds
- Concurrent test prevention via atomic flags
- 6 unit tests for concurrency and idempotency

### Entity Management

**Frontend Store** ✅ Complete (`entity-store.ts`):
- Reactive subscription system with `listeners` pattern
- CRUD operations: `addEntity()`, `updateEntity()`, `deleteEntity()`
- Backend-persisting operations: `updateEntityWithPersist()`, `deleteEntityWithPersist()`
- Text matching with word boundary detection
- Deduplication of overlapping matches
- 9 new tests for persistence behavior

**Backend Extraction** ✅ Complete (`entities.py`):
- `ExtractEntities()` RPC delegates to `NerService`
- `UpdateEntity()` RPC for text/category mutations
- `DeleteEntity()` RPC for single-entity removal
- Converts domain `NamedEntity` → proto `ExtractedEntity`
- 10 integration tests covering mutations, NOT_FOUND, INVALID_ARGUMENT

**Completed in Sprint 4**:
- ✅ `PinEntity` RPC for user confirmation (uses `update_pinned()` repo method)
- ✅ Entity persistence to `named_entities` table
- ✅ Frontend `pinEntity()` in `useEntityExtraction` hook

**Completed in Sprint 8**:
- ✅ `UpdateEntity` RPC for editing entity text/category
- ✅ `DeleteEntity` RPC for single-entity removal
- ✅ `entity_repo.update()` and `entity_repo.delete()` methods
- ✅ Frontend wiring via `EntityManagementPanel` with meetingId prop

---

## Scope (Revised)

| Task | Effort | Risk | Status |
|------|--------|------|--------|
| Add Tauri audio test commands (Rust) | Medium | Medium | ✅ Complete |
| Wire audio test UI to Tauri commands | Low | Low | ✅ Complete |
| Add `UpdateEntity` gRPC endpoint | Low | Low | ✅ Complete |
| Add `DeleteEntity` gRPC endpoint | Low | Low | ✅ Complete |
| Wire entity panel to backend mutations | Low | Low | ✅ Complete |

> **Removed from scope**: `PinEntity`/`UnpinEntity` — already complete in Sprint 4.

---

## Implementation Plan

### Task 1: Tauri Audio Test Commands

**Files to Modify**:
- `client/src-tauri/src/commands/audio.rs` — Add audio test command handlers
- `client/src-tauri/src/audio/` — Reuse existing device utilities (no new module)

**Rust Command Handlers** (`audio.rs`):
```rust
use tauri::{command, Window};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

static INPUT_TEST_RUNNING: AtomicBool = AtomicBool::new(false);
static OUTPUT_TEST_RUNNING: AtomicBool = AtomicBool::new(false);

#[derive(Clone, serde::Serialize)]
pub struct AudioLevelEvent {
    pub level: f32,  // 0.0 - 1.0 normalized
    pub peak: f32,
}

#[command]
pub async fn start_input_test(window: Window, device_id: Option<String>) -> Result<(), String> {
    if INPUT_TEST_RUNNING.swap(true, Ordering::SeqCst) {
        return Err("Input test already running".into());
    }

    // Spawn async task to capture audio levels
    tokio::spawn(async move {
        let input = cpal::default_host()
            .input_devices()
            .map_err(|e| format!("Failed to enumerate devices: {e}"))?
            .find(|d| device_id.as_ref().map_or(true, |id| d.name().ok().as_ref() == Some(id)))
            .ok_or("Device not found")?;

        // Build input stream and emit level events
        let stream = input.build_input_stream(
            &config,
            move |data: &[f32], _| {
                let rms = (data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt();
                let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
                let _ = window.emit("audio-level", AudioLevelEvent {
                    level: rms.min(1.0),
                    peak: peak.min(1.0),
                });
            },
            |err| eprintln!("Audio stream error: {err}"),
            None,
        )?;

        stream.play()?;

        while INPUT_TEST_RUNNING.load(Ordering::SeqCst) {
            tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
        }

        Ok::<_, String>(())
    });

    Ok(())
}

#[command]
pub fn stop_input_test() -> Result<(), String> {
    INPUT_TEST_RUNNING.store(false, Ordering::SeqCst);
    Ok(())
}

#[command]
pub async fn start_output_test(window: Window, device_id: Option<String>) -> Result<(), String> {
    if OUTPUT_TEST_RUNNING.swap(true, Ordering::SeqCst) {
        return Err("Output test already running".into());
    }

    tokio::spawn(async move {
        let output = cpal::default_host()
            .output_devices()?
            .find(|d| device_id.as_ref().map_or(true, |id| d.name().ok().as_ref() == Some(id)))
            .ok_or("Device not found")?;

        // Generate 440Hz sine wave test tone
        let sample_rate = config.sample_rate.0 as f32;
        let mut sample_clock = 0f32;
        let frequency = 440.0;

        let stream = output.build_output_stream(
            &config,
            move |data: &mut [f32], _| {
                for sample in data.iter_mut() {
                    sample_clock += 1.0;
                    *sample = (sample_clock * frequency * 2.0 * std::f32::consts::PI / sample_rate).sin() * 0.3;
                }
            },
            |err| eprintln!("Audio stream error: {err}"),
            None,
        )?;

        stream.play()?;

        // Play for 2 seconds then stop
        tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
        OUTPUT_TEST_RUNNING.store(false, Ordering::SeqCst);

        Ok::<_, String>(())
    });

    Ok(())
}

#[command]
pub fn stop_output_test() -> Result<(), String> {
    OUTPUT_TEST_RUNNING.store(false, Ordering::SeqCst);
    Ok(())
}
```

**Register Commands** (`main.rs`):
```rust
mod commands;

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            // ... existing handlers ...
            commands::audio::start_input_test,
            commands::audio::stop_input_test,
            commands::audio::start_output_test,
            commands::audio::stop_output_test,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### Task 2: Wire Audio Test UI to Tauri Commands

**Files to Modify**:
- `client/src/hooks/use-audio-devices.ts`

**Desktop Integration**:
```typescript
// Replace stub with actual Tauri invocations
const testInput = async () => {
  if (isTauriEnvironment()) {
    // Listen for level events
    const unlisten = await listen<AudioLevelEvent>('audio-level', (event) => {
      setInputLevel(event.payload.level);
      setInputPeak(event.payload.peak);
    });

    await invoke('start_input_test', { deviceId: selectedInputDevice });

    // Cleanup on unmount or stop
    return () => {
      invoke('stop_input_test');
      unlisten();
    };
  } else {
    // Existing browser implementation
    return testInputBrowser();
  }
};

const testOutput = async () => {
  if (isTauriEnvironment()) {
    setIsTestingOutput(true);
    await invoke('start_output_test', { deviceId: selectedOutputDevice });
    // Auto-stops after 2 seconds
    setTimeout(() => setIsTestingOutput(false), 2000);
  } else {
    return testOutputBrowser();
  }
};
```

### Task 3: Add Entity Mutation gRPC Endpoints

> **Note**: `PinEntity` RPC already exists (Sprint 4). Only `UpdateEntity` and `DeleteEntity` need implementation.

**Files to Modify**:
- `src/noteflow/grpc/proto/noteflow.proto` — Add messages
- `src/noteflow/grpc/_mixins/entities.py` — Add RPCs
- `src/noteflow/domain/ports/repositories.py` — Add repository methods
- `src/noteflow/infrastructure/persistence/repositories/entity_repo.py` — Implement mutations

**Proto Additions**:
```protobuf
message UpdateEntityRequest {
  string meeting_id = 1;
  string entity_id = 2;
  string text = 3;
  string category = 4;
}
message UpdateEntityResponse {
  ExtractedEntity entity = 1;
}

message DeleteEntityRequest {
  string meeting_id = 1;
  string entity_id = 2;
}
message DeleteEntityResponse {
  bool success = 1;
}
```

**gRPC Mixin** (`entities.py`):
```python
from noteflow.grpc._mixins.errors import abort_invalid_argument, abort_not_found

async def UpdateEntity(
    self: ServicerHost,
    request: noteflow_pb2.UpdateEntityRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.UpdateEntityResponse:
    """Update an extracted entity's text or category."""
    meeting_id = parse_meeting_id(request.meeting_id)
    if meeting_id is None:
        await abort_invalid_argument(context, "Invalid meeting_id")

    async with self._create_repository_provider() as uow:
        entity = await uow.entities.update(
            entity_id=UUID(request.entity_id),
            text=request.text or None,
            category=request.category or None,
        )
        if entity is None:
            await abort_not_found(context, "Entity", request.entity_id)

    return noteflow_pb2.UpdateEntityResponse(
        entity=self._entity_to_proto(entity),
    )

async def DeleteEntity(
    self: ServicerHost,
    request: noteflow_pb2.DeleteEntityRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.DeleteEntityResponse:
    """Delete an extracted entity."""
    async with self._create_repository_provider() as uow:
        success = await uow.entities.delete(UUID(request.entity_id))
    return noteflow_pb2.DeleteEntityResponse(success=success)
```

**Repository additions** (`entity_repo.py`):
```python
async def update(
    self,
    entity_id: UUID,
    text: str | None = None,
    category: str | None = None,
) -> NamedEntity | None:
    """Update entity text and/or category."""
    stmt = select(NamedEntityModel).where(NamedEntityModel.id == entity_id)
    result = await self._session.execute(stmt)
    model = result.scalar_one_or_none()
    if model is None:
        return None
    if text is not None:
        model.text = text
    if category is not None:
        model.category = category
    await self._session.flush()
    return self._to_domain(model)

async def delete(self, entity_id: UUID) -> bool:
    """Delete a single entity by ID."""
    stmt = delete(NamedEntityModel).where(NamedEntityModel.id == entity_id)
    result = await self._session.execute(stmt)
    return result.rowcount > 0
```

### Task 4: Wire Entity Panel to Backend Mutations

**Files to Modify**:
- `client/src/api/interface.ts` — Add methods
- `client/src/api/tauri-adapter.ts` — Implement methods
- `client/src/components/entity-management-panel.tsx` — Call API on edits
- `client/src/lib/entity-store.ts` — Refresh from backend after mutations

**Entity Store Update**:
```typescript
// Add backend sync method
export async function syncEntityFromBackend(
  meetingId: string,
  entityId: string,
): Promise<void> {
  const api = getAPI();
  const entities = await api.extractEntities(meetingId, { forceRefresh: false });
  const updated = entities.find(e => e.id === entityId);
  if (updated) {
    updateEntity(entityId, updated);
  }
}

// Modify updateEntity to call backend
export async function updateEntityWithPersist(
  meetingId: string,
  entityId: string,
  changes: Partial<Entity>,
): Promise<void> {
  const api = getAPI();
  const result = await api.updateEntity(meetingId, entityId, changes);
  updateEntity(entityId, result);
}
```

---

## Behavioral Test Specifications

### Rust Audio Command Tests

**File**: `client/src-tauri/src/commands/audio_test.rs`

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_input_test_prevents_concurrent_runs() {
        // Start first test
        let result1 = start_input_test_sync(None);
        assert!(result1.is_ok(), "First input test should start");

        // Second test should fail
        let result2 = start_input_test_sync(None);
        assert!(result2.is_err(), "Concurrent input test should be rejected");
        assert!(result2.unwrap_err().contains("already running"));

        // Cleanup
        stop_input_test().unwrap();
    }

    #[test]
    fn test_output_test_generates_audible_frequency() {
        // This test verifies the sine wave generation produces expected frequency
        let samples = generate_test_samples(440.0, 48000.0, 1000);
        let frequency = estimate_frequency(&samples, 48000.0);
        assert!((frequency - 440.0).abs() < 5.0, "Generated frequency should be ~440Hz");
    }

    #[test]
    fn test_stop_input_test_is_idempotent() {
        // Stopping when not running should not error
        let result = stop_input_test();
        assert!(result.is_ok());
    }
}
```

### Backend Entity Mutation Tests

**File**: `tests/grpc/test_entity_mutations.py`

```python
"""Behavioral tests for entity mutation gRPC endpoints."""

import pytest
from uuid import uuid4

from noteflow.grpc import noteflow_pb2


class TestEntityUpdate:
    """Test updating extracted entities."""

    async def test_update_entity_text_persists_change(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_entities: str,
    ) -> None:
        """Updating entity text persists the change."""
        # Arrange: get existing entities
        extract_response = await grpc_client.ExtractEntities(
            noteflow_pb2.ExtractEntitiesRequest(meeting_id=meeting_with_entities)
        )
        entity = extract_response.entities[0]
        original_text = entity.text

        # Act: update the text
        new_text = "Updated Entity Name"
        update_response = await grpc_client.UpdateEntity(
            noteflow_pb2.UpdateEntityRequest(
                meeting_id=meeting_with_entities,
                entity_id=entity.id,
                text=new_text,
            )
        )

        # Assert: returned entity has new text
        assert update_response.entity.text == new_text
        assert update_response.entity.text != original_text

        # Assert: re-fetching shows updated text
        refetch = await grpc_client.ExtractEntities(
            noteflow_pb2.ExtractEntitiesRequest(meeting_id=meeting_with_entities)
        )
        updated = next(e for e in refetch.entities if e.id == entity.id)
        assert updated.text == new_text

    async def test_update_entity_category_changes_classification(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_entities: str,
    ) -> None:
        """Updating entity category reclassifies the entity."""
        extract_response = await grpc_client.ExtractEntities(
            noteflow_pb2.ExtractEntitiesRequest(meeting_id=meeting_with_entities)
        )
        entity = extract_response.entities[0]

        update_response = await grpc_client.UpdateEntity(
            noteflow_pb2.UpdateEntityRequest(
                meeting_id=meeting_with_entities,
                entity_id=entity.id,
                category="COMPANY",
            )
        )

        assert update_response.entity.category == "COMPANY"

    async def test_update_nonexistent_entity_returns_not_found(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_entities: str,
    ) -> None:
        """Updating nonexistent entity returns NOT_FOUND."""
        with pytest.raises(grpc.RpcError) as exc_info:
            await grpc_client.UpdateEntity(
                noteflow_pb2.UpdateEntityRequest(
                    meeting_id=meeting_with_entities,
                    entity_id=str(uuid4()),
                    text="New text",
                )
            )

        assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND


# NOTE: TestEntityPinning tests already exist in Sprint 4's test suite.
# See tests/grpc/test_entities.py for pin/unpin coverage.


class TestEntityDeletion:
    """Test deleting entities."""

    async def test_delete_entity_removes_from_list(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_entities: str,
    ) -> None:
        """Deleting an entity removes it from extraction results."""
        extract_response = await grpc_client.ExtractEntities(
            noteflow_pb2.ExtractEntitiesRequest(meeting_id=meeting_with_entities)
        )
        initial_count = len(extract_response.entities)
        entity_to_delete = extract_response.entities[0]

        delete_response = await grpc_client.DeleteEntity(
            noteflow_pb2.DeleteEntityRequest(
                meeting_id=meeting_with_entities,
                entity_id=entity_to_delete.id,
            )
        )

        assert delete_response.success

        refetch = await grpc_client.ExtractEntities(
            noteflow_pb2.ExtractEntitiesRequest(meeting_id=meeting_with_entities)
        )
        assert len(refetch.entities) == initial_count - 1
        assert all(e.id != entity_to_delete.id for e in refetch.entities)

    async def test_delete_nonexistent_entity_returns_false(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_entities: str,
    ) -> None:
        """Deleting nonexistent entity returns success=false."""
        response = await grpc_client.DeleteEntity(
            noteflow_pb2.DeleteEntityRequest(
                meeting_id=meeting_with_entities,
                entity_id=str(uuid4()),
            )
        )

        assert not response.success
```

### Frontend Entity Persistence Tests

**File**: `client/src/lib/entity-store.test.ts`

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as api from '../api';
import {
  updateEntityWithPersist,
  syncEntityFromBackend,
  getEntities,
  setEntitiesFromExtraction,
} from './entity-store';

describe('entity-store persistence', () => {
  beforeEach(() => {
    // Reset store state
    setEntitiesFromExtraction([]);
  });

  describe('updateEntityWithPersist', () => {
    it('calls backend API and updates local store', async () => {
      const mockEntity = { id: '1', text: 'Updated', category: 'PERSON' };
      const mockApi = {
        updateEntity: vi.fn().mockResolvedValue(mockEntity),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      setEntitiesFromExtraction([{ id: '1', text: 'Original', category: 'PERSON' }]);

      await updateEntityWithPersist('meeting-1', '1', { text: 'Updated' });

      expect(mockApi.updateEntity).toHaveBeenCalledWith('meeting-1', '1', { text: 'Updated' });
      expect(getEntities()[0].text).toBe('Updated');
    });

    it('does not update local store if API fails', async () => {
      const mockApi = {
        updateEntity: vi.fn().mockRejectedValue(new Error('API error')),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      setEntitiesFromExtraction([{ id: '1', text: 'Original', category: 'PERSON' }]);

      await expect(
        updateEntityWithPersist('meeting-1', '1', { text: 'Updated' })
      ).rejects.toThrow('API error');

      expect(getEntities()[0].text).toBe('Original');
    });
  });

  describe('syncEntityFromBackend', () => {
    it('refreshes entity from backend extraction', async () => {
      const backendEntities = [
        { id: '1', text: 'Backend Version', category: 'COMPANY' },
      ];
      const mockApi = {
        extractEntities: vi.fn().mockResolvedValue(backendEntities),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      setEntitiesFromExtraction([{ id: '1', text: 'Local Version', category: 'PERSON' }]);

      await syncEntityFromBackend('meeting-1', '1');

      expect(getEntities()[0].text).toBe('Backend Version');
      expect(getEntities()[0].category).toBe('COMPANY');
    });
  });
});
```

### Audio Device Hook Tests

**File**: `client/src/hooks/use-audio-devices.test.ts`

```typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAudioDevices } from './use-audio-devices';

// Mock Tauri
vi.mock('@tauri-apps/api/core', () => ({
  invoke: vi.fn(),
}));

vi.mock('@tauri-apps/api/event', () => ({
  listen: vi.fn().mockResolvedValue(() => {}),
}));

describe('useAudioDevices - Desktop Mode', () => {
  beforeEach(() => {
    vi.mocked(invoke).mockReset();
  });

  describe('input testing', () => {
    it('invokes start_input_test command with device ID', async () => {
      vi.mocked(invoke).mockResolvedValue(undefined);

      const { result } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.testInputDevice('device-123');
      });

      expect(invoke).toHaveBeenCalledWith('start_input_test', {
        deviceId: 'device-123',
      });
    });

    it('sets up level event listener during test', async () => {
      const mockUnlisten = vi.fn();
      vi.mocked(listen).mockResolvedValue(mockUnlisten);
      vi.mocked(invoke).mockResolvedValue(undefined);

      const { result } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.testInputDevice('device-123');
      });

      expect(listen).toHaveBeenCalledWith('audio-level', expect.any(Function));
    });

    it('stops test and removes listener on stopInputTest', async () => {
      const mockUnlisten = vi.fn();
      vi.mocked(listen).mockResolvedValue(mockUnlisten);
      vi.mocked(invoke).mockResolvedValue(undefined);

      const { result } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.testInputDevice('device-123');
      });

      await act(async () => {
        await result.current.stopInputTest();
      });

      expect(invoke).toHaveBeenCalledWith('stop_input_test');
      expect(mockUnlisten).toHaveBeenCalled();
    });
  });

  describe('output testing', () => {
    it('invokes start_output_test command', async () => {
      vi.mocked(invoke).mockResolvedValue(undefined);

      const { result } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.testOutputDevice('speaker-456');
      });

      expect(invoke).toHaveBeenCalledWith('start_output_test', {
        deviceId: 'speaker-456',
      });
    });

    it('sets isTestingOutput to false after timeout', async () => {
      vi.useFakeTimers();
      vi.mocked(invoke).mockResolvedValue(undefined);

      const { result } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.testOutputDevice('speaker-456');
      });

      expect(result.current.isTestingOutput).toBe(true);

      await act(async () => {
        vi.advanceTimersByTime(2000);
      });

      expect(result.current.isTestingOutput).toBe(false);

      vi.useRealTimers();
    });
  });
});
```

---

## Acceptance Criteria

### Functional

- [x] Desktop input test shows real-time microphone levels
- [x] Desktop output test plays audible test tone
- [x] Entity text edits persist across page refresh
- [x] Entity category changes persist across page refresh
- [x] Pinned entities remain pinned after app restart *(Sprint 4)*
- [x] Deleted entities do not reappear on re-extraction

### Technical

- [x] Audio test uses cpal crate for cross-platform audio
- [x] Audio events emitted at ≥20 FPS for smooth visualization (50ms interval)
- [x] Entity mutations use proper transaction semantics (SQLAlchemy UoW)
- [x] No data loss on concurrent entity edits (atomic flags for audio, DB transactions for entities)

### Quality Gates

- [x] `cargo test --lib` passes for Rust audio commands (9 tests)
- [x] `pytest tests/integration/test_grpc_servicer_database.py` passes (10 entity mutation tests)
- [x] `npm run test` passes for frontend (183 tests, including 9 new persistence tests)
- [x] No new platform-specific code paths without fallbacks (graceful degradation when meetingId absent)

---

## Rollback Plan

1. **Audio test failures**: Re-enable "not wired" toast for desktop
2. **Entity persistence issues**: Revert to in-memory-only store
3. **Platform audio crashes**: Add try/catch with graceful degradation

---

## Post-Sprint

- Add audio device selection to Settings UI
- Implement audio test timeout (prevent runaway tests)
- Add entity batch operations (pin all, delete selected)
- Cross-platform audio test verification (Windows/macOS/Linux)
