WordloopWordloop
WorkMeeting RecordingTechnical Design DocSchemas

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

Rendering architecture map...

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

Rendering architecture map...

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 FlowColumns 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
AudioStoredProgressEventREAD + 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

Rendering architecture map...

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_id on all transcript_segments with that speaker_label and inserts a row into meeting_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_edited column. When a user edits a system task the application promotes source from '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.

TableIndexRationale
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 NULLLive segment revision hot-path
synthesis(meeting_id)Synthesis reads alongside meeting fetch
topics(meeting_id), (meeting_id) WHERE deleted_at IS NULLSynthesis 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 NULLSynthesis 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 NULLTask 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 NULLMeeting list — active rows only
idempotency_keys(expires_at)Periodic TTL cleanup

On this page