# 03 — Data Model ## On-disk layout Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2). ``` %LOCALAPPDATA%\WhispAssist\ ├── wa.db # SQLite index (relations, metadata, FTS) ├── settings.json # app settings (theme, provider, storage, presets) ├── models\ # downloaded models (whisper, diarization) │ ├── whisper-base.q5.bin │ ├── seg-pyannote-3.0.onnx │ └── spk-eres2net.onnx └── meetings\ └── \ # one folder per meeting (uuid) ├── audio.wav # canonical recording — present ONLY if "Record" was on (ADR-0009). Mic on → stereo split (L=mic/"You", R=loopback/"Speaker", `audio_layout=split`); mic off → summed. Playback folds to mono; export folds to dual-mono (ADR-0005 Phase 3.5) ├── voiceprint.wav # mic-only 16kHz sample for "You" re-matching on reprocess of a SUMMED recording (FR-SPK); lives/dies with audio.wav ├── transcript.json # canonical transcript (segments+speakers+timings) ├── manual_notes.json # raw user-authored notes captured live during recording ├── notes.md # the final notes document: manual notes + transcript, merged at finalize ├── summary.json # LLM summary, decisions, action items (if generated) └── briefs/ # feature briefs distilled from this meeting (ADR-0011), if any └── .json # agent-ready spec served via the MCP `get_feature_brief` tool ``` A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4) copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted) into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps, duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle` reconstructs each such folder under a fresh meeting id — the portable format for moving recordings between computers. Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not (FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and `summary.json` are **derived** and regenerable (regenerable only while the audio still exists — i.e. for recorded meetings). `notes.md` is **generated once, at finalize**, by merging `manual_notes.json` (freeform notes typed live during the recording, plus any per-moment annotations — see below) with the rendered, speaker-tagged transcript (`notes::MarkdownNotes::merge`). After that it is the user's own document, freely editable via `update_notes` exactly like before this changed — nothing re-renders or overwrites it afterward. In particular, renaming or merging a speaker after finalize updates the `speakers` table and the live UI display, but does **not** retroactively rewrite text already baked into `notes.md` (same as any other manual edit isn't retroactively touched either — this was a pre-existing clobber bug this redesign also fixes: renaming a speaker used to silently overwrite the whole file). A crash-recovery finalize (T2.8) and a post-finalize batch re-transcription (T3.8) both re-render `notes.md` from scratch and so both re-read `manual_notes.json` from disk to fold the same manual notes back in. ### `manual_notes.json` ```jsonc { "schema": 1, "freeform_md": "string — the user's running notes, typed live in the Notes pane while recording", "segment_notes": [ { "anchor_ms": 12345, "text": "string", "created_at": 1735000000, "updated_at": 1735000010 } ] } ``` `segment_notes[].anchor_ms` is a timestamp into the recording (a clicked transcript segment's `start_ms`), not a segment id — a later batch re-transcription can renumber/regenerate segment ids, but never moves the moment in time a note was attached to. At merge time, each note is placed right after whichever transcript paragraph's time span contains its `anchor_ms`; a note whose anchor doesn't land inside any paragraph surfaces under an "Other notes" section instead of being silently dropped. Written to disk on every edit via `update_live_notes`/`set_segment_note` (`04-api-contracts.md`) — live-session only, same write-through-for-crash-safety spirit as `transcript.json` accumulating during recording. ## SQLite schema (`wa.db`) ```sql -- A meeting is the top-level record. CREATE TABLE meetings ( id TEXT PRIMARY KEY, -- uuid v4 title TEXT NOT NULL DEFAULT 'Untitled meeting', started_at INTEGER NOT NULL, -- unix epoch seconds ended_at INTEGER, -- null while recording duration_secs INTEGER, -- finalized on stop folder_path TEXT NOT NULL, -- absolute path to meeting folder audio_path TEXT, -- audio.wav; NULL if not retained (ADR-0009) recorded INTEGER NOT NULL DEFAULT 0, -- 1 = audio retained as .wav, 0 = transcript-only status TEXT NOT NULL, -- recording|transcribing|ready|recovering|error language TEXT, -- detected/selected language code backend_used TEXT, -- npu|nvidia|amd|intel|cpu model_used TEXT, -- e.g. whisper-base audio_layout TEXT, -- split|summed|NULL; how audio.wav's channels are laid out (0009, ADR-0005 Phase 3.5) calendar_event_id TEXT, -- FK -> calendar_events.id (nullable) template_id TEXT, -- note-template id (T8.1, FR-NOTE-5); catalog is -- a built-in Rust list (notes::templates), not a table created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); -- Internal speakers detected per meeting (S1, S2, …) and their assigned names. CREATE TABLE speakers ( id TEXT PRIMARY KEY, -- uuid meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, label TEXT NOT NULL, -- 'S1','S2',… (internal, stable per meeting) display_name TEXT, -- user/participant name (nullable) participant_id TEXT REFERENCES participants(id), -- if mapped to a calendar attendee color TEXT, -- UI color hint merged_into TEXT, -- non-null: this label folds into another label's -- row at render/export time (T4.5, FR-SPK-3); segment -- speaker IDs in storage are never rewritten (FR-SPK-5) UNIQUE(meeting_id, label) ); -- People known from calendar/.pst; reused across meetings for continuity. CREATE TABLE participants ( id TEXT PRIMARY KEY, -- uuid name TEXT NOT NULL, email TEXT, -- nullable UNIQUE(name, email) ); CREATE TABLE meeting_participants ( -- attendee list per meeting meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, participant_id TEXT NOT NULL REFERENCES participants(id), role TEXT, -- organizer|required|optional PRIMARY KEY (meeting_id, participant_id) ); -- Calendar events imported from .pst (or future sources). CREATE TABLE calendar_events ( id TEXT PRIMARY KEY, -- uuid (stable from source uid if available) source TEXT NOT NULL, -- pst|graph|ics subject TEXT, organizer TEXT, starts_at INTEGER, ends_at INTEGER, description TEXT, raw_uid TEXT -- source's own id for dedup ); -- Re-importing the same event (matched by source+raw_uid) updates it in place (T6.2). CREATE UNIQUE INDEX idx_calendar_events_source_uid ON calendar_events(source, raw_uid) WHERE raw_uid IS NOT NULL; -- Attendees per imported calendar event, populated at import time (T6.1) — -- distinct from meeting_participants, which links a *recording* to people -- once a meeting is attached to an event (T6.3/T6.6). CREATE TABLE calendar_event_participants ( calendar_event_id TEXT NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE, participant_id TEXT NOT NULL REFERENCES participants(id), role TEXT, -- organizer|required|optional PRIMARY KEY (calendar_event_id, participant_id) ); CREATE TABLE tags ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE ); CREATE TABLE meeting_tags ( meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (meeting_id, tag_id) ); -- Action items parsed from LLM output, then user-confirmed. CREATE TABLE action_items ( id TEXT PRIMARY KEY, meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, text TEXT NOT NULL, owner TEXT, -- assignee name (nullable) due_at INTEGER, -- nullable confirmed INTEGER NOT NULL DEFAULT 0, -- 0=suggested, 1=confirmed by user reminder_set INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); -- Feature briefs distilled from a meeting for coding-agent handoff (ADR-0011). The JSON body -- lives in the meeting's briefs/ folder; this table indexes it for the MCP tools. CREATE TABLE feature_briefs ( id TEXT PRIMARY KEY, -- uuid meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, title TEXT NOT NULL, target_repo TEXT, -- optional repo hint for the agent path TEXT NOT NULL, -- briefs/.json exposed INTEGER NOT NULL DEFAULT 0, -- visible to the MCP server? (scope control, FR-MCP-3) created_at INTEGER NOT NULL ); -- Audit of what an MCP client (agent) read (FR-MCP-5). CREATE TABLE mcp_access_log ( id TEXT PRIMARY KEY, at INTEGER NOT NULL, tool TEXT NOT NULL, -- e.g. get_feature_brief meeting_id TEXT, -- subject, if any client TEXT -- client-reported name, if provided ); -- Configured sync/upload destinations (ADR-0010). Credentials are NOT stored here — -- only a reference into the OS credential store (FR-SYNC-6). CREATE TABLE sync_targets ( id TEXT PRIMARY KEY, -- uuid name TEXT NOT NULL, -- user label, e.g. "Home Nextcloud" kind TEXT NOT NULL, -- webdav|onedrive|dropbox|box provider_hint TEXT, -- nextcloud|owncloud|cloudreve|seafile|synology|generic base_url TEXT, -- WebDAV URL (kind=webdav) remote_base_path TEXT NOT NULL DEFAULT '/WhispAssist', username TEXT, -- WebDAV username (secret is in credential store) credential_ref TEXT NOT NULL, -- key into OS credential store enabled INTEGER NOT NULL DEFAULT 0, -- off by default (FR-SYNC-1) upload_transcript INTEGER NOT NULL DEFAULT 1, upload_notes INTEGER NOT NULL DEFAULT 1, upload_summary INTEGER NOT NULL DEFAULT 1, upload_recording INTEGER NOT NULL DEFAULT 0, -- only meaningful if a meeting is recorded trigger_on_finalize INTEGER NOT NULL DEFAULT 1, allow_plaintext_lan INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-7 encrypt_before_upload INTEGER NOT NULL DEFAULT 0, -- FR-SYNC-10 created_at INTEGER NOT NULL ); -- One upload job per (artifact, target). The durable queue (FR-SYNC-5). CREATE TABLE sync_jobs ( id TEXT PRIMARY KEY, -- uuid target_id TEXT NOT NULL REFERENCES sync_targets(id) ON DELETE CASCADE, meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE, artifact TEXT NOT NULL, -- transcript|notes|summary|recording local_path TEXT NOT NULL, remote_path TEXT NOT NULL, sha256 TEXT, -- skip-if-unchanged idempotency status TEXT NOT NULL, -- pending|uploading|done|failed|skipped attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT, next_attempt_at INTEGER, -- backoff schedule bytes_total INTEGER, bytes_sent INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, UNIQUE(target_id, meeting_id, artifact) ); -- Full-text search over transcript + notes (FR-SEARCH-1). CREATE VIRTUAL TABLE meeting_fts USING fts5( meeting_id UNINDEXED, title, transcript_text, notes_text, tokenize = 'porter unicode61' ); CREATE INDEX idx_meetings_started ON meetings(started_at DESC); CREATE INDEX idx_speakers_meeting ON speakers(meeting_id); CREATE INDEX idx_action_meeting ON action_items(meeting_id); CREATE INDEX idx_syncjobs_status ON sync_jobs(status, next_attempt_at); CREATE INDEX idx_briefs_meeting ON feature_briefs(meeting_id); ``` Schema migrations are versioned (e.g. `sqlx::migrate!`), each migration numbered and forward-only. ## `transcript.json` (canonical transcript) ```jsonc { "schema": 1, "meeting_id": "f1c2…", "language": "en", "model": "whisper-base", "backend": "nvidia", "segments": [ { "id": 0, "start_ms": 1240, // offset from recording start "end_ms": 4880, "speaker": "S1", // internal speaker id; name resolved at render time "text": "Let's start with the roadmap.", "confidence": 0.91, // optional "interim": false, // true while streaming, false once finalized }, ], "speakers": [ { "label": "S1", "display_name": "Alex" }, { "label": "S2", "display_name": null }, ], } ``` Names are stored as a mapping and applied at render/export — segments keep the internal `speaker` label so re-diarization or renaming never requires rewriting every segment (FR-SPK-5). ## `summary.json` ```jsonc { "schema": 1, "generated_at": 1751299200, "provider": "ollama", "model": "llama3", "summary_md": "## Summary\n…", "decisions": ["Adopt Tauri for the shell"], "action_items": [ // Same shape as the `action_items` table row (ActionItem) minus the row // never having existed yet: `id`/`due_at` are null until the user // reviews and confirms a drafted item (FR-LLM-3), at which point // `confirm_action_items` creates the real row. { "id": null, "text": "Send the API contract draft", "owner": "Jordan", "due_at": null, "confirmed": false, }, ], } ``` ## `briefs/.json` (feature brief — ADR-0011) Agent-ready spec the MCP `get_feature_brief` tool returns. Designed to drop straight into a coding agent's context. Written by `create_feature_brief` (M1); one file per brief under the meeting's `briefs/` folder, **sealed at rest with the vault** when unlocked (T8.8), exactly like `summary.json`. The `feature_briefs` table indexes it (id, meeting_id, title, target_repo, path, exposed) for `list_feature_briefs` and the MCP scope check — the file is the source of truth; the row is the index. The IPC `FeatureBrief` type (`04-api-contracts.md`) is the subset returned to the UI / MCP: everything below **except** the `schema`/provenance envelope (`generated_at`, `provider`, `model`, `source`). ```jsonc { "schema": 1, "id": "b7a1…", "meeting_id": "f1c2…", "generated_at": 1751299200, // envelope: which model distilled this, and when "provider": "ollama", "model": "llama3", "title": "Bulk CSV export for the reporting view", "problem": "Customer can't get their data out for offline analysis.", "desired_outcome": "One-click CSV export of the current filtered report.", "acceptance_criteria": [ "Export button on the report toolbar", "Respects active filters and column order", "Streams large exports without blocking the UI", ], "target_repo": "acme/reporting-web", // optional hint the user supplies at create time "context_excerpts": [ // verbatim transcript quotes that ground the request — NOT model paraphrase; // the builder selects them from the real transcript (speaker = resolved display name) { "speaker": "Customer", "text": "We really need to pull this into our own spreadsheets." }, ], "source": { "meeting_title": "Acme quarterly sync", "at": 1751299200 }, } ``` Field presence: `title`, `problem`, `desired_outcome` are always strings (the builder falls back to the meeting title / `""` on a sparse model reply); `acceptance_criteria` and `context_excerpts` may be empty arrays. Every `context_excerpts[].text` is a verbatim substring of a real transcript segment (the M1 grounding invariant, asserted by the golden-transcript test). ## `settings.json` ```jsonc { "theme": "system", // system|light|dark "storage_root": "%LOCALAPPDATA%\\WhispAssist", "retention": { "max_age_days": 90, "max_size_gb": 10 }, "transcription": { "model": "base", "language": "auto", "batch_reprocess": false }, "hardware": { "preferred_backend": "auto", "low_overhead": false }, "recording": { "default_record": false, // "Record this meeting" default — OFF (FR-REC-1) "consent_acknowledged": false, // set true after the one-time consent notice (FR-REC-2) }, "llm": { "provider": "ollama", // ollama|custom|anthropic|openai|off (ADR-0007/0011; "openai" not yet wired) "endpoint": "http://localhost:11434", "model": "llama3", "stream": true, // API keys for hosted providers (anthropic|openai) live in the OS credential store, not here. "hosted_ai_acknowledged": false, // one-time "data leaves your device" notice ack (T10.3, ADR-0011) }, // Sync target rows live in wa.db (sync_targets); secrets live in the OS credential store. // settings.json only holds the global default. No credentials here (FR-SYNC-6). "sync": { "enabled": false }, // master off switch — OFF by default (FR-SYNC-1) // Local MCP server (ADR-0011). OFF by default; inbound loopback only; token in credential store. "mcp": { "enabled": false, // FR-MCP-1 "transport": "http", // http (127.0.0.1/mcp) | stdio "port": 4849, "expose": "selected", // none | selected | all (scope control, FR-MCP-3) "expose_recordings": false, // never serve .wav unless explicitly true (FR-MCP-3) }, "privacy": { "encrypt_at_rest": false }, // Optional MS Graph calendar source (M4.4, T8.9, FR-CAL-6). Opt-in, explicit consent via OAuth // PKCE — OFF by default. `credential_ref` points into the OS credential store; the token itself // is never written here (same invariant as sync credentials, FR-SYNC-6). "calendar": { "graph_enabled": false, "graph_credential_ref": null }, } ``` ## Retention & recovery semantics - **Retention** (FR-STORE-2): a background job deletes whole meeting folders + rows once a meeting exceeds the age/size policy, oldest first; never runs while a meeting is `recording`/`transcribing`. - **Recovery** (FR-REL-1): on startup, any meeting whose **working WAV still exists** but whose `transcript.json` is missing/partial is marked `recovering`; the user can re-run transcription from the audio. (A cleanly finalized non-recorded meeting has no audio and needs no recovery; an interrupted one still has its working WAV, so it is always recoverable.) - **Sync** (FR-SYNC-5): on startup and after each finalize, the `sync` service scans `sync_jobs` for `pending`/`failed` jobs whose `next_attempt_at` has passed and resumes them with backoff. Deleting a meeting cascades to its `sync_jobs` (local rows only; already-uploaded remote copies are left to the user/server).