Postgres Schema
Target-state PostgreSQL schema for the meeting-recording feature — table definitions, index design, and trade-off rationale mapped to TDD contracts and data flows.
Postgres Schema
Target-state PostgreSQL schema for the meeting-recording feature. Maps each domain area to the TDD's contracts and data flows, explains why each table exists, and documents the trade-offs.
Canonical file: services/wordloop-core/db/schema.sql
Overview
1 — Recording Lifecycle
Contracts: Recording, Audio · Flows: Start Recording, Live Audio, Stop Recording, Audio Gap Recovery
A live recording session has its own lifecycle distinct from a meeting's — audio config, sequence numbering for gap detection, ML session coordination, and an 8-state shutdown sequence. This lives in a separate recordings table rather than on meetings so that non-recording meetings stay unaffected and the two lifecycles remain independent.
State Machine
recordings
1:1 extension of meetings — meeting_id is both PK and FK, enforcing one recording per meeting at the database level.
CREATE TABLE recordings (
meeting_id UUID PRIMARY KEY REFERENCES meetings (id) ON DELETE CASCADE,
-- PK + FK → meetings(id). Enforces 1:1 at the DB level.
-- TEXT + CHECK rather than a PG enum — volatile 8-state machine;
-- enums can never drop values once added
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN (
'active', -- Recording in progress
'stopping', -- User/timeout triggered stop
'draining_ml', -- ML session drain initiated
'awaiting_gap_upload', -- Gap plan computed, waiting for client chunks
'composing_audio', -- GCS object composition
'post_processing', -- Summary, topics, tasks generation
'completed', -- Terminal success
'failed' -- Terminal failure
)),
ml_session_id TEXT, -- AssemblyAI session ref for pre-warm and drain
-- Audio config captured at session start
sample_rate INTEGER NOT NULL, -- Carried through to chunk storage and composition
channel_count SMALLINT NOT NULL,
mime_type TEXT NOT NULL, -- e.g. "audio/webm;codecs=opus"
-- Sequence tracking — powers gap detection + AudioStoredProgressEvent
-- (emitted every 10s so the client can trim its OPFS buffer)
last_audio_sequence BIGINT NOT NULL DEFAULT 0, -- Highest sequence received from browser
last_stored_sequence BIGINT NOT NULL DEFAULT 0, -- Highest sequence written to GCS
-- Gap between these two drives recovery
gap_plan JSONB, -- [{start_sequence, end_sequence}]
-- Write-once-read-few → JSONB over a normalized table
-- Serves GET /meetings/{id}/recording/missing-chunks
started_at TIMESTAMPTZ NOT NULL,
stopped_at TIMESTAMPTZ, -- NULL while active
duration_ms BIGINT, -- Computed at stop from the delta
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Partial — concurrent session guard on every StartRecordingCommand;
-- index-only scan over a tiny active subset
CREATE INDEX idx_recordings_active ON recordings (status) WHERE status = 'active';recording_status_history
Append-only audit log of every state transition. Mirrors the transcription_status_history pattern.
CREATE TABLE recording_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recording_id UUID NOT NULL REFERENCES recordings (meeting_id) ON DELETE CASCADE,
status TEXT NOT NULL,
status_message TEXT, -- Optional detail (e.g. error reason on failure)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Chronological ordering for debug and audit queries
CREATE INDEX idx_recording_status_history_recording_id
ON recording_status_history (recording_id, created_at);Flow Mapping
| Data Flow | Columns Touched |
|---|---|
| Start Recording (Flow 1) | INSERT: status='active', audio config, ml_session_id |
| Live Audio (Flow 2) | UPDATE last_audio_sequence on each chunk batch |
| AudioStoredProgressEvent | READ + UPDATE last_stored_sequence on GCS write confirmation |
| Stop Recording (Flow 9) | Transition through all 6 shutdown states |
| Gap Recovery (Flow 7/16) | WRITE gap_plan, READ for GET /missing-chunks |
| Inactivity Timeout (Flow 8) | Transition to stopping after 5 min no audio |
| Duration Warning (Flow 10) | READ started_at to compute elapsed; auto-stop at 4h |
2 — Audio Storage
Contracts: Audio · Flows: Stop Recording (composition phase), Audio Playback
audio_objects
1:1 table keyed on meeting_id. The composition metadata columns are populated asynchronously after GCS composition completes. audio_version defaults to 1 so all rows are valid from creation. Using "audio_objects" instead of "meeting_audio_files" explicitly maps to GCS objects and matches the TDD contract.
CREATE TABLE audio_objects (
meeting_id UUID PRIMARY KEY REFERENCES meetings (id) ON DELETE CASCADE,
storage_path TEXT NOT NULL,
duration_ms BIGINT, -- Total audio duration for playback UI
size_bytes BIGINT, -- GCS composed file size — used for HTTP Range calculations
mime_type TEXT, -- Audio format for playback response headers
audio_version INTEGER NOT NULL DEFAULT 1,
-- Incremented on recomposition (e.g. after gap recovery)
-- Transcriptions carry this value to identify which audio they cover
checksum_sha256 TEXT, -- SHA-256 for tiered integrity validation
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);3 — Speaker Identification
Contracts: Person, speaker-labels endpoint in Meeting · Flows: Live Speaker ID (Flow 4), Speaker Labelling (Flow 7)
During recording the diarizer produces raw labels ("Speaker 1", "Speaker 2") that need progressive matching to known people via voice embeddings. A speaker label can exist in an unmatched state with no person reference — the meeting_attendees junction table (which requires both meeting_id and person_id) can't model this, so labels live in their own table.
Identification Pipeline
meeting_speaker_labels
Corrected 2026-09-09 to match the shipped schema (services/wordloop-core/db/schema.sql). The design originally called for a surrogate-keyed table with a match_status state machine and per-label match-attempt tracking; the implementation ships a simpler shape — (meeting_id, speaker_label) is itself the primary key, and there is no match_status/match_attempts/confidence on this table. Matching state lives in the speaker-identification pipeline (see Person & Speaker Identity); this table only records the current assignment, and is re-applied to transcript_segments whenever segments are appended (live) or replaced (final batch rebuild) — so assignments survive transcript rebuilds.
CREATE TABLE meeting_speaker_labels (
meeting_id UUID NOT NULL REFERENCES meetings (id) ON DELETE CASCADE,
speaker_label TEXT NOT NULL, -- Raw diarizer label, e.g. "Speaker 1"
person_id UUID NOT NULL REFERENCES people (id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (meeting_id, speaker_label)
);
CREATE INDEX idx_meeting_speaker_labels_person_id ON meeting_speaker_labels (person_id);Application cascade: When a label is assigned or reassigned, the service layer updates
person_idon alltranscript_segmentswith thatspeaker_labeland inserts a row intomeeting_attendees.
people
Voice model status and confidence live here to support embedding-based speaker matching.
CREATE TABLE people (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
display_name TEXT NOT NULL,
full_name TEXT,
title TEXT,
role TEXT,
email TEXT,
company TEXT,
tags JSONB,
voice_model_status TEXT DEFAULT 'untrained'
CHECK (voice_model_status IN (
'untrained', -- No voice data collected
'training', -- Model training in progress
'ready', -- Embeddings available for matching
'failed' -- Training failed; user can retry
)),
voice_confidence REAL, -- 0–1 aggregate confidence across training samples
-- REAL not DECIMAL: ML scores are approximate floats
voice_vector vector(192), -- ECAPA-TDNN embedding; see ADR 0008
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, display_name)
);
CREATE INDEX idx_people_voice_vector_hnsw ON people USING hnsw (voice_vector vector_cosine_ops);4 — Transcription Pipeline
Contracts: Transcription · Flows: Live Audio → Transcription (Flow 2), Post-Meeting Processing (Flow 11), Status Lifecycle (Flow 12)
Live recording introduces streaming segments — interim results that get revised in-place when finals arrive — alongside the existing batch upload flow. The synthesizing status covers the post-transcription phase where summary, topics, and talking points are generated.
transcriptions
CREATE TABLE transcriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meeting_id UUID NOT NULL REFERENCES meetings (id) ON DELETE CASCADE,
status TEXT NOT NULL
CHECK (status IN (
'pending',
'transcribing',
'synthesizing', -- post-transcription: summary, topics, talking points
'completed',
'failed'
)),
status_message TEXT,
is_degraded BOOLEAN NOT NULL DEFAULT false,
audio_version INTEGER, -- Matches audio_objects.audio_version
-- A meeting can have multiple transcriptions (live + batch reprocess);
-- this identifies which composed audio each one covers
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_transcriptions_meeting_id ON transcriptions (meeting_id);transcript_segments
CREATE TABLE transcript_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transcription_id UUID NOT NULL REFERENCES transcriptions (id) ON DELETE CASCADE,
person_id UUID REFERENCES people (id) ON DELETE SET NULL,
speaker_label TEXT,
text TEXT NOT NULL,
start_ms BIGINT NOT NULL, -- Millisecond offset; integer not decimal
end_ms BIGINT NOT NULL DEFAULT 0,
confidence REAL NOT NULL DEFAULT 0, -- ML score; approximate float
is_final BOOLEAN NOT NULL DEFAULT true,
is_highlighted BOOLEAN NOT NULL DEFAULT false,
feature_vector vector(192), -- ECAPA-TDNN embedding; matches people.voice_vector, see ADR 0008
source_sequence BIGINT, -- ML-assigned monotonic sequence per live session
-- A final segment with the same value replaces the interim one
-- NULL for batch upload segments
revision SMALLINT NOT NULL DEFAULT 1 -- Incremented on interim → final transition
-- Used by the UI for in-place replacement without layout shift
);
CREATE INDEX idx_transcript_time ON transcript_segments (transcription_id, start_ms);
CREATE INDEX idx_transcript_segments_feature_vector_hnsw
ON transcript_segments USING hnsw (feature_vector vector_cosine_ops);
-- Hot-path for live segment revision: find the existing segment for this source_sequence to update
CREATE INDEX idx_transcript_segments_source_sequence
ON transcript_segments (transcription_id, source_sequence)
WHERE source_sequence IS NOT NULL;5 — Synthesis & Insights
Contracts: Synthesis · Flows: Live Insights Pipeline (Flow 3), Post-Meeting Processing (Flow 11)
The synthesis tables — synthesis, topics, topic_segments, talking_points, talking_point_segments — structure the ML-generated post-meeting artifacts. Using a dedicated synthesis table (rather than polluting meetings) establishes a clear data boundary for ML operations.
synthesis
A 1:1 table for the overall meeting summary and headline. Isolating this from meetings allows atomic writes from the ML pipeline without locking the core meeting row, and supports regeneration flows smoothly.
CREATE TABLE synthesis (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meeting_id UUID NOT NULL UNIQUE REFERENCES meetings (id) ON DELETE CASCADE,
headline TEXT,
summary TEXT,
key_points JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Used to efficiently fetch synthesis alongside a meeting
CREATE INDEX idx_synthesis_meeting_id ON synthesis (meeting_id);topics
Extracted thematic topics. Tracks is_final to distinguish between live drafts and post-meeting finalized output.
CREATE TABLE topics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meeting_id UUID NOT NULL REFERENCES meetings (id) ON DELETE CASCADE,
title TEXT NOT NULL,
summary TEXT,
is_final BOOLEAN NOT NULL DEFAULT false, -- false during live generation; true after post-meeting synthesis
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_topics_meeting_id ON topics (meeting_id);
CREATE INDEX idx_topics_active ON topics (meeting_id) WHERE deleted_at IS NULL;topic_segments
Junction table mapping topics to the specific transcript segments they encompass. This powers UI navigation from a topic directly to the relevant transcript sections.
CREATE TABLE topic_segments (
topic_id UUID NOT NULL REFERENCES topics (id) ON DELETE CASCADE,
segment_id UUID NOT NULL REFERENCES transcript_segments (id) ON DELETE CASCADE,
PRIMARY KEY (topic_id, segment_id)
);
CREATE INDEX idx_topic_segments_segment_id ON topic_segments (segment_id);talking_points
Specific actionable points or decisions extracted from the meeting. Tied to a topic if applicable.
CREATE TABLE talking_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
meeting_id UUID NOT NULL REFERENCES meetings (id) ON DELETE CASCADE,
topic_id UUID REFERENCES topics (id) ON DELETE SET NULL,
content TEXT NOT NULL,
is_final BOOLEAN NOT NULL DEFAULT false, -- false during live generation; true after post-meeting synthesis
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_talking_points_meeting_id ON talking_points (meeting_id);
CREATE INDEX idx_talking_points_topic_id ON talking_points (topic_id);
-- Post-meeting reconciliation soft-deletes live drafts and inserts finals;
-- active queries only scan non-deleted rows
CREATE INDEX idx_talking_points_active ON talking_points (meeting_id) WHERE deleted_at IS NULL;talking_point_segments
Junction table mapping a talking point to its source transcript segments. Powers citation links in the UI.
CREATE TABLE talking_point_segments (
talking_point_id UUID NOT NULL REFERENCES talking_points (id) ON DELETE CASCADE,
segment_id UUID NOT NULL REFERENCES transcript_segments (id) ON DELETE CASCADE,
PRIMARY KEY (talking_point_id, segment_id)
);
CREATE INDEX idx_talking_point_segments_segment_id ON talking_point_segments (segment_id);6 — Task Management & Reconciliation
Contracts: Task (PUT /meetings/{id}/tasks/system) · Flows: Live Tasks & Reconciliation (Milestone 6)
During recording the ML pipeline extracts tasks in real-time (source = 'system'). Users can create their own (source = 'user') or edit system-generated ones. At post-meeting processing the ML regenerates tasks from the final transcript and reconciles:
- Preserve all tasks where
source = 'user'(user-created or user-edited) - Replace tasks where
source = 'system'(unedited AI-generated)
Design decision: no
is_user_editedcolumn. When a user edits a system task the application promotessourcefrom'system'to'user'. The existing column already carries the reconciliation signal — a separate boolean would be redundant.
tasks
Sub-tasks are modeled via parent_task_id self-reference — no separate sub-tasks table.
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'completed')),
source public.task_source_enum NOT NULL DEFAULT 'system',
-- 'system' = ML-generated; 'user' = user-created or user-edited
-- Promotion from 'system' → 'user' is the reconciliation signal
due_date DATE,
assigned_to UUID REFERENCES people (id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings (id) ON DELETE SET NULL,
parent_task_id UUID REFERENCES tasks (id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Partial — active tasks are the dominant query path
CREATE INDEX idx_tasks_active ON tasks (user_id, status, due_date) WHERE deleted_at IS NULL;
CREATE INDEX idx_tasks_meeting_id ON tasks (meeting_id);
CREATE INDEX idx_tasks_assigned_to ON tasks (assigned_to);
CREATE INDEX idx_tasks_parent_task_id ON tasks (parent_task_id);7 — Meeting Core
Contracts: Meeting · Flows: Notes Auto-Save (Flow 4), all meeting CRUD
meetings
-- 'live' covers meetings created from a live recording session
-- Append-only — PG enums support adding values but never removing them
CREATE TYPE public.meeting_source_enum AS ENUM ('recording', 'upload', 'text', 'anecdotal', 'live');
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
title TEXT NOT NULL,
source_type public.meeting_source_enum NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
notes TEXT, -- Live scratchpad, auto-saved with 500ms debounce via PATCH /meetings/{id}
-- Distinct from the `notes` table (freeform notes about people and meetings)
-- Inline here to avoid a join on the hot-path PK lookup during active recording
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Meeting list always filters non-deleted; partial index eliminates soft-deleted row scans entirely
CREATE INDEX idx_meetings_user_active ON meetings (user_id, created_at DESC) WHERE deleted_at IS NULL;8 — Idempotency
Contracts: Infrastructure
All POST endpoints accept an Idempotency-Key header. Retried requests with the same key return the cached response rather than creating duplicates.
idempotency_keys
-- Composite PK (key, user_id) — the only access pattern is lookup by this pair;
-- no surrogate UUID needed
CREATE TABLE idempotency_keys (
key TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
-- User-scoped: the same key from two different users won't conflict
response_status SMALLINT NOT NULL, -- Cached HTTP status code for replay
response_body JSONB NOT NULL, -- Cached response body
expires_at TIMESTAMPTZ NOT NULL, -- 24h default TTL
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (key, user_id)
);
-- Used by the periodic cleanup job: DELETE WHERE expires_at < now()
CREATE INDEX idx_idempotency_keys_expires_at ON idempotency_keys (expires_at);Index Design
PostgreSQL does not automatically create indexes on foreign key columns. Without explicit indexes, joins require full child-table scans and parent-row deletes must acquire share locks on the child table. Every FK in this schema carries an index.
Soft-delete tables use partial indexes scoped to WHERE deleted_at IS NULL. Active-record queries — the overwhelming majority of traffic — never scan deleted rows.
| Table | Index | Rationale |
|---|---|---|
recordings | (status) WHERE status = 'active' | Concurrent session guard — scans only the active subset |
transcriptions | (meeting_id) | Meeting detail page loads transcription by meeting |
transcription_status_history | (transcription_id, created_at) | Chronological status timeline |
transcript_segments | (transcription_id, source_sequence) WHERE source_sequence IS NOT NULL | Live segment revision hot-path |
synthesis | (meeting_id) | Synthesis reads alongside meeting fetch |
topics | (meeting_id), (meeting_id) WHERE deleted_at IS NULL | Synthesis reads; active-only query path |
topic_segments | (segment_id) | Segment to topic back-reference |
meeting_speaker_labels | (person_id) | Person → labels back-reference (meeting-scoped lookup is covered by the (meeting_id, speaker_label) primary key) |
talking_points | (meeting_id), (topic_id), (meeting_id) WHERE deleted_at IS NULL | Synthesis reads; active-only query path |
talking_point_segments | (segment_id) | Segment to talking point back-reference |
tasks | (user_id, status, due_date) WHERE deleted_at IS NULL | Task list — active rows only |
tasks | (meeting_id), (assigned_to), (parent_task_id) | Meeting tasks, assignee view, subtask tree |
notes | (user_id) | User note listing |
person_summaries | (person_id), (author_id) | Person detail page |
meetings | (user_id, created_at DESC) WHERE deleted_at IS NULL | Meeting list — active rows only |
idempotency_keys | (expires_at) | Periodic TTL cleanup |