Skip to content

Messaging System Tables

Conversations (conversations)

Purpose: Permanent container for all interactions between a user and the system across any channel

Use Case Example: When a user sends a WhatsApp message to Sana, a conversation record tracks all their interactions on that channel for a specific project and role.

Column Type Description
id UUID Unique identifier (auto-generated)
user_id UUID Reference to user who owns this conversation
channel TEXT Communication channel: whatsapp/sms/web/in_app
channel_identifier TEXT Phone number for WhatsApp/SMS, session ID for web
notification_sender_id UUID Reference to notification sender (business phone number) for this conversation
status TEXT active/archived/blocked/current (only one current per channel/sender)
project_id UUID Project this conversation is associated with
project_relationship_id UUID Specific project relationship (role) this conversation is for
last_message_at TIMESTAMPTZ Timestamp of most recent message
rate_limit_data JSONB Stores rate limiting information for the conversation
created_at TIMESTAMPTZ Creation timestamp
updated_at TIMESTAMPTZ Last update timestamp
created_by_user_id UUID User who created this conversation record (defaults to auth.uid())
updated_by_user_id UUID User who last updated this conversation record (defaults to auth.uid())

Key Features:

  • One conversation per user per channel per project per role (project_relationship) per notification sender
  • Support for 'current' status - only one conversation can be current per user/channel/notification_sender
  • Never deleted, only archived or blocked (except when project is deleted - cascade delete applies)
  • Permanent audit trail of all interactions
  • UNIQUE index idx_conversation_unique_channel_project_sender on (user_id, channel, channel_identifier, project_id, notification_sender_id) — one conversation per user/channel/phone/project/sender. This is the DB-level guard against the check-then-insert race that used to create duplicate conversation rows; createConversation now upserts on this key (ON CONFLICT DO NOTHING, then re-reads the winner). channel is NOT NULL so the key is total. (Per the domain model (project, user) is 1:1 with project_relationship, so project_relationship_id is not part of the key — it would be redundant.)
  • Partial unique index (idx_conversation_one_current_per_user_sender) ensures only one current conversation per user/channel/channel_identifier/notification_sender
  • CASCADE DELETE on project_id foreign key - conversations are automatically deleted when their associated project is deleted
  • Automatic timestamp tracking via triggers
  • Allows separate conversations per role when user has multiple roles in same project
  • Multi-sender support: Different business phone numbers (notification_senders) maintain separate conversation contexts
  • rate_limit_data JSONB structure tracks message counts and throttle status:
    {
      "daily_message_count": 0,
      "daily_reset_at": null,
      "hourly_message_count": 0,
      "hourly_reset_at": null,
      "minute_message_count": 0,
      "minute_reset_at": null,
      "is_throttled": false,
      "throttled_until": null
    }
    

Sender resolution (notification_sender_id):

The sender a conversation is filed under must match the WhatsApp business number the project's messages flow through, because the inbound webhook resolves the sender deterministically from the message's phone_number_id. Two mechanisms keep them aligned:

  • At creation (invite / first access grant): the sender is resolved server-side from the project's project_type category, not from any client-supplied value. The project's project_type category id is looked up and matched against notification_senders.category_id. If the project resolves to a project type that has no matching sender, creation fails loudly rather than defaulting — the default (is_default, "Sana - Media") sender is used only when the project has no project_type category at all. This prevents non-default project types (e.g. Company/Personal Accounting) from being mis-filed under the Media sender.
  • On inbound (self-healing): if no current conversation exists for the sender the message arrived on, the webhook looks for a conversation the user already has for a project of that sender's category but filed under a different sender, and reassigns it to the correct sender (promoting it to current; the trigger demotes the previous current). If nothing matches, it surfaces the failure rather than fabricating a conversation. This recovers conversations that were mis-filed before the creation fix.

Conversation Instances (conversation_instances)

Purpose: Time-based grouping of messages within a conversation (8-hour windows)

Use Case Example: User has a conversation about budgets in the morning. After 8 hours of inactivity, their afternoon messages start a new instance with fresh context. If the conversation was triggered by a notification (e.g., "Budget threshold reached"), the notification_id links back to that original notification.

Column Type Description
id UUID Unique identifier
conversation_id UUID Reference to parent conversation
notification_id UUID Reference to notification that created or triggered this conversation instance
instance_number INTEGER Sequential number within conversation
status TEXT active/closed/summarized
started_at TIMESTAMPTZ When this instance began
closed_at TIMESTAMPTZ When instance was closed
last_message_at TIMESTAMPTZ Timestamp of most recent message
message_count INTEGER Total messages in this instance
auto_closed_reason TEXT message_limit/time_limit/abuse_detected/manual
created_at TIMESTAMPTZ Creation timestamp
updated_at TIMESTAMPTZ Last update timestamp
created_by_user_id UUID User who created this conversation instance record (defaults to auth.uid())
updated_by_user_id UUID User who last updated this conversation instance record (defaults to auth.uid())

Key Features:

  • 8-hour inactivity creates new instance
  • Limits of 500 messages per instance
  • AI summaries generated on closure
  • Maintains conversation continuity
  • Optional link to triggering notification via notification_id

Instance Lifecycle

Statuses move in one direction: activeclosedsummarized. An instance is active while it is accepting messages, closed once it has been retired (stamped with an auto_closed_reason), and summarized once conversation_summaries holds its AI summary.

One-active invariant: a conversation has at most one active instance, enforced in the database by the partial unique index conversation_instances_one_active_uidx on (conversation_id) WHERE status = 'active'. Closed and summarized siblings are unconstrained, so a conversation accumulates an ordered history of retired instances behind exactly one live one. Duplicate active instances previously split a conversation's messages across rows and made the message cap behave unpredictably.

Cap rollover: check_rate_limits_and_abuse measures the per_instance cap against the ACTIVE instance's message_count alone (never the sum across the conversation's history — that sum only grows, so the cap would be permanent). On reaching it the checker returns allowed = false, limit_type = 'instance', reset_at = NULL, which is an instruction to roll over rather than a rejection. The caller then invokes rollover_conversation_instance, which in one transaction closes the capped instance (status = 'closed', auto_closed_reason = 'message_limit') and inserts its successor (status = 'active', instance_number = MAX + 1), then retries the message. The rollover serializes on the parent conversations row lock, so concurrent inbound messages produce exactly one successor; losers of that race get already_current. An active abuse action refuses the rollover outright. Full outcome contract: VIEWS_AND_FUNCTIONS.md § Messaging System Functions.

Summarization is a separate later step: the closed instance is summarized and moves to summarized. conversation_summaries_instance_uidx makes this idempotent — one summary per instance, so a retried summarization conflicts instead of appending a second, divergent summary.

Messages (messages)

Purpose: Individual messages within conversation instances

Use Case Example: User sends "Show me my spending", Sana responds with budget analysis - both stored as message records.

Column Type Description
id UUID Unique identifier
conversation_instance_id UUID Reference to parent instance (nullable)
notification_delivery_id UUID Reference to notification_delivery if triggered by notification
approval_request_id UUID Reference to approval_request for approval workflow messages
payment_id UUID Reference to payment for payment-reconciliation queries (parallels approval_request_id)
direction TEXT inbound (from user), outbound (to user), or internal (for AI context)
sender_type TEXT user, assistant, system, functions, tool, or tool_calls
sender_user_id UUID Reference to user (NULL for system messages)
entity_id UUID Reference to entity (contact/company) that sent or received message
project_id UUID Reference to project this message belongs to (required)
source TEXT Source: WhatsApp, Email, Manual, or Approval Message (default: WhatsApp)
external_id TEXT External message ID (e.g., email Message-ID header) for deduplication
parent_id UUID Reference to parent message for threading (e.g., email replies)
root_message_id UUID Reference to root message in thread (auto-populated via trigger)
origin_message_id UUID Links Approval Message to source WhatsApp/Email message (for inbound queries/replies)
content_text TEXT Plain text message content
content_json JSONB Rich content (buttons, media, interactive) or WhatsApp/Email payload
whatsapp_metadata JSONB WhatsApp-specific data (message_id, flow_token, process_name)
ai_function_id UUID Reference to AI function if this is a function call/response
include_in_ai_context BOOLEAN Whether to include this message in AI conversation context
status TEXT pending/sent/delivered/read/failed (default: pending)
metadata JSONB Extra metadata added to the message
channel_address TEXT The channel-specific address (phone for WhatsApp, email for email) used for this message
error_message TEXT Error details if status = failed
sent_at TIMESTAMPTZ When message was sent
delivered_at TIMESTAMPTZ When message was delivered
read_at TIMESTAMPTZ When message was read
created_at TIMESTAMPTZ Creation timestamp
created_by_user_id UUID User who created this message record

Key Features:

  • Full message history preserved
  • Rich content support via JSON (also stores WhatsApp payload for outbound messages)
  • Delivery tracking per message with 'initiated' as default status
  • WhatsApp-specific metadata storage
  • AI function tracking for bot interactions
  • Context control for AI conversations
  • Support for 'internal' direction for system-generated AI context messages
  • conversation_instance_id is nullable for flexibility
  • channel_address field tracks the channel-specific address (phone for WhatsApp, email for email) used for this message
  • Multiple sender_type values to support AI message roles
  • Optional entity_id links messages to specific contacts/companies for compliance tracking
  • Message Threading Support:
  • parent_id creates parent-child relationships for message replies (e.g., email threads)
  • root_message_id denormalized field pointing to the root of the thread (auto-populated via trigger)
  • external_id prevents duplicate processing of messages (e.g., same email processed twice)
  • Trigger automatically sets root_message_id by recursively traversing parent_id chain
  • Enables efficient querying of entire message threads without recursive CTEs
  • Smart tagging logic: Reply messages with ALL duplicate attachments skip tagging; replies with NEW attachments tag both reply and root message
  • Approval Message Architecture:
  • source = 'Approval Message' identifies UI thread messages for approval workflows
  • Separate from WhatsApp/Email delivery records which have their own source values
  • approval_request_id links messages to their approval workflow
  • origin_message_id links UI thread messages to their source WhatsApp/Email inbound message (for queries/replies via external channels)
  • v_approval_messages view filters by source='Approval Message' to show only UI thread
  • Payment Message Architecture (parallels Approval Message):
  • payment_id links messages to a payment (typed FK, parallels approval_request_id for approvals)
  • source = 'Supporting Document Request' is the only producer today (root + replies), but the view is source-agnostic so future producers (notes, ad-hoc queries) only need to stamp payment_id
  • parent_id distinguishes the root request (outbound) from threaded replies (inbound)
  • metadata.outcome carries reply sub-types (uploaded, missing_justification) so the chat surface can branch without joining a sibling table
  • v_payment_messages view exposes the full thread with a derived sender_name (entity-first, user fallback) and surfaces source + metadata so callers narrow by thread shape without re-joining
  • Check constraints ensure data integrity:
  • Inbound messages must have sender_type='user' with non-null sender_user_id and created_by_user_id
  • Outbound and internal messages cannot have sender_type='user'

Message Attachments (message_attachments)

Purpose: Links messages to file attachments

Use Case Example: User sends photo of receipt via WhatsApp, attachment linked to their message.

Column Type Description
id UUID Unique identifier
message_id UUID Reference to message
attachment_id UUID Reference to attachment record
created_at TIMESTAMPTZ Creation timestamp

Key Features:

  • Many-to-many relationship
  • Reuses existing attachment system
  • Supports multiple attachments per message

Conversation Summaries (conversation_summaries)

Purpose: AI-generated summaries of closed conversation instances

Use Case Example: After 8-hour conversation about project planning, AI summarizes key decisions and action items for future context.

Column Type Description
id UUID Unique identifier
conversation_instance_id UUID Reference to summarized instance
summary_text TEXT Natural language summary
key_points JSONB Array of key points discussed
topics TEXT[] Extracted topics for searchability
sentiment TEXT positive/neutral/negative/mixed
action_items JSONB Array of identified follow-ups
metadata JSONB Additional AI-generated insights
created_at TIMESTAMPTZ Creation timestamp
created_by_user_id UUID User who triggered the summary generation

Key Features:

  • Preserves context across instances
  • Searchable topics array
  • Sentiment analysis included
  • Action items tracked

Message Outbox (message_outbox)

Purpose: One row per outbound send intent, written before any provider call so a send survives process death

Use Case Example: Sana answers three inbound WhatsApp messages from the same person within a second. Each reply is enqueued as an intent first; a dispatcher then sends them one at a time, six seconds apart, instead of the provider rejecting two of them and losing those replies.

Column Type Description
id UUID Unique identifier (auto-generated)
idempotency_key TEXT Deterministic logical identity of the intent (e.g. reply:{wamid}:{purpose}, nd:{delivery_id}, read:{wamid}). UNIQUE — re-enqueuing the same logical send adopts the existing row
channel channel_type Delivery channel this intent is dispatched over; part of the pair identity the provider rate limit applies to
sender_key TEXT Channel-specific sender identity (WhatsApp: Meta phone_number_id). Half of the rate-limited pair
recipient_key TEXT Channel-specific recipient identity (WhatsApp: E.164 number). Half of the rate-limited pair
send_kind message_outbox_send_kind Which provider client call the channel dispatcher replays for this row
payload JSONB Serialised client-call arguments, replayed verbatim by the dispatcher on every attempt
payload_version INTEGER Schema version of payload, so a dispatcher can reject or adapt a payload written by an older deploy (default 1)
log_context JSONB Correlation fields carried to the structured delivery-failure event (parentMessageId, ingestionOutcome, raiseCase)
notification_delivery_id UUID Notification delivery this send fulfils, when the intent originates from the notification fan-out (ON DELETE SET NULL)
state message_outbox_state Lifecycle state (default pending). Only pending rows are claimable; ambiguous rows are never retried and never pruned
attempt_count INTEGER Number of wire attempts started. Incremented at attempt start, so an attempt that died mid-wire still counts against the ceiling
max_attempts INTEGER Attempt ceiling for this intent. A retryable failure at or above it becomes terminal
next_attempt_at TIMESTAMPTZ Earliest time this row may be claimed. Backoff is computed application-side and written here on requeue (default now())
claim_id UUID Fence token of the dispatcher currently holding this row; every state write is compare-and-swapped against it
provider_message_id TEXT Provider message id once the send is accepted, whether at acceptance or by later ambiguity resolution
last_error_code INTEGER Provider error code of the most recent failed attempt
last_error_status INTEGER HTTP status of the most recent failed attempt
last_error JSONB Raw provider error body of the most recent failed attempt
persisted_at TIMESTAMPTZ When post-terminal bookkeeping completed: on an accepted row the messages row + delivery update, on a failed row the delivery failure mark. NULL on a settled row marks it still owed
ambiguous_resolved_at TIMESTAMPTZ When a status-webhook correlation resolved an ambiguous row to accepted
created_at TIMESTAMPTZ Creation timestamp; also the FIFO ordering key within a pair
updated_at TIMESTAMPTZ Last modification timestamp, maintained by trigger. Retention basis for pruning

Key Features:

  • State machine: pendingsending (claimed by a dispatcher) → accepted | failed | ambiguous. A retryable failure under the attempt ceiling returns sendingpending; at the ceiling it becomes failed. An ambiguous intent leaves that state two ways: resolve_message_outbox_ambiguous settles it on a status webhook (accepted on a delivery, failed on a provider failed status — proof of rejection is as final an answer as proof of delivery), or the sweep re-drives a resolution that never landed.
  • Only the claim holder or lease recovery may transition a claimed row. A status webhook can outrun the dispatcher's own call, so resolve_message_outbox_ambiguous on a row still sending records EVIDENCE only — the provider_message_id for an acceptance, a {"source":"status_webhook","status":"failed"} marker in last_error for a rejection — with no state, claim or lease change. Transitioning it (or releasing its lease) would let a second dispatcher claim the pair while the first is still on the wire.
  • Every settling path CONSUMES that evidence rather than parking past it, so no ordering of webhook and dispatcher leaves a row waiting for an answer that already arrived:
  • the dispatcher's own fail_message_outbox_attempt checks it before parking an ambiguous classification, returning accepted_by_evidence (with the wamid, so persistence runs immediately) or failed_by_evidence;
  • recover_stale_message_outbox_leases checks the same two markers when the dispatcher died mid-wire, settling to accepted or failed instead of ambiguous;
  • the sweep re-drives the resolution for any already-parked ambiguous row that carries a provider_message_id, which is the case where the webhook's own resolve call failed after the row had parked.
  • The intent exists before the provider call. Messaging providers rate-limit per conversational pair (the WhatsApp Cloud API allows one message per six seconds per business phone number / recipient). Sends issued directly from request handlers share no pacing cursor and leave no record of an attempt that died mid-wire, so a burst is rejected (Meta error 131056) and the rejected message is lost.
  • ambiguous is never retried and never pruned. A send whose outcome is unknown — the connection died after the request left but before a response arrived — may already have been delivered, so a retry could duplicate a delivered message. The row parks until a status webhook settles it.
  • The channel is a first-class dimension, not a table-name prefix: it reuses the schema-wide channel_type, and sender_key/recipient_key are opaque channel-scoped identity strings. Every claim and every lease sweep is channel-scoped, so a dispatcher never picks up another channel's work and a second channel needs only a dispatcher — no schema change.
  • persisted_at tracks post-terminal app-side bookkeeping, and is what bounds the sweep's repair windows. complete_message_outbox_attempt(p_requires_persistence => false) stamps it immediately (a read receipt has no message row to write); otherwise it stays NULL until the work is genuinely done — the messages row and delivery update on an accepted row, the delivery failure mark on a failed one. Both repair scans filter persisted_at IS NULL, so a repaired row leaves its window permanently. Bounding by age instead would let the oldest N rows be the same N rows every cycle, starving every newer arrival; and stamping on a write that FAILED would drop a row out of the window with its delivery still pending, which is exactly the state the repair exists to fix.
  • Indexes: idx_message_outbox_pending_head (partial on state = 'pending', keyed (channel, sender_key, recipient_key, created_at, id)) makes the FIFO head lookup a bounded probe rather than a scan of the pair's backlog; idx_message_outbox_pending_due (partial, on next_attempt_at) serves the due-scan; idx_message_outbox_sending_pair (partial on state = 'sending', keyed (channel, sender_key, recipient_key)) serves the claim path's in-flight pair probe, which runs on every claim attempt; idx_message_outbox_accepted_unpersisted (partial on state = 'accepted' AND persisted_at IS NULL, on updated_at) serves the persistence-retry scan and idx_message_outbox_failed_unpersisted (partial on state = 'failed' AND persisted_at IS NULL, on updated_at) serves the delivery-repair scan — kept as two indexes rather than one widened predicate so neither scan probes rows it can never act on; plus an FK index on notification_delivery_id.
  • Service-role only. RLS is enabled with four deny-all authenticated policies (SELECT/INSERT/UPDATE/DELETE, all false), and authenticated/anon receive no grant at all — the deny-all policies document the default-deny explicitly. Every writer (the dispatcher, the sweeper cron, the status webhook) is a server-side service-role caller. The table is not in the supabase_realtime publication.
  • Automatic updated_at tracking via trigger.

Message Outbox Pairs (message_outbox_pairs)

Purpose: Serialisation lock and pacing cursor for one (channel, sender, recipient) pair

Use Case Example: Two dispatcher instances run concurrently. Each claims a different pair rather than queueing behind the same one, and neither can put a second message on the same conversational pair until that pair's provider interval has elapsed.

Column Type Description
id UUID Unique identifier (auto-generated)
channel channel_type Delivery channel half of the pair identity
sender_key TEXT Channel-specific sender identity (WhatsApp: Meta phone_number_id)
recipient_key TEXT Channel-specific recipient identity (WhatsApp: E.164 number)
last_attempt_started_at TIMESTAMPTZ When the most recent attempt on this pair went out. Stamped at attempt START, never at completion
claim_id UUID Fence token of the dispatcher currently holding this pair; NULL when free
lease_expires_at TIMESTAMPTZ Expiry of the current hold. A lapsed lease is reclaimable, and its in-flight row is settled by the lease-recovery sweep
created_at TIMESTAMPTZ Creation timestamp
updated_at TIMESTAMPTZ Last modification timestamp, maintained by trigger

Key Features:

  • UNIQUE (channel, sender_key, recipient_key) (message_outbox_pairs_pair_key) is the pair identity — the same sender/recipient strings under two channels are two distinct pairs. enqueue_message_outbox creates the row on demand (ON CONFLICT DO NOTHING) without disturbing an existing lease or pacing cursor.
  • Pacing is stamped at attempt START, never at completion. A provider call that fails still consumed the pair's slot at the moment it went out, so crediting the pair only on success would let a failing send retry immediately and re-trip the very limit this exists to respect.
  • The pair queue is STRICT FIFO. Only the pair's oldest pending row (ordered created_at, id) is ever claimable, so a backed-off head BLOCKS its pair even when a strictly newer row is already due. Head-of-line blocking is the intended behaviour: conversational replies delivered out of order are worse than replies delivered late.
  • At most one dispatcher holds a pair at a time. A claim is eligible only when all four conditions hold together — the lease is free or lapsed, the pair has NO row in state sending, the pacing interval has elapsed since the pair's last attempt STARTED, and the pair's own head row is due.
  • A lapsed lease over a sending row belongs to the recovery sweep, not the claim path. recover_stale_message_outbox_leases matches that row on the PAIR's claim_id, so claiming past it would overwrite the pair claim and orphan the sending row, break FIFO, and — if the old dispatcher is merely slow rather than dead — allow two concurrent sends on one pair.
  • Pair rows are collected by prune_message_outbox, and only when genuinely spent: no lease, idle past the accepted-retention window, and no message_outbox rows of any state. Nothing else deletes them, so without this a system that messaged many recipients once would accumulate a pair per conversation forever — and every claim scans this table. A pair carrying any row (including a pending or ambiguous one the prune deliberately kept) keeps its pacing cursor and serialisation lock.
  • Index: idx_message_outbox_pairs_lease_expires_at (partial on claim_id IS NOT NULL) serves the expired-lease sweep.
  • Service-role only, with the same four deny-all authenticated policies and no authenticated/anon grants; not in the supabase_realtime publication.

Message Outbox Attempts (message_outbox_attempts)

Purpose: Append-only log of wire attempts, one row per provider call

Use Case Example: A send is abandoned mid-wire and later proven delivered by a status webhook. The intent advances to accepted, but its attempt row keeps the ambiguous verdict, so the record still shows the send was once in doubt.

Column Type Description
id UUID Unique identifier (auto-generated)
outbox_id UUID Outbox intent this attempt belongs to (ON DELETE CASCADE)
attempt_number INTEGER Ordinal within its intent, equal to the intent's attempt_count at the moment the attempt started
claim_id UUID Fence token of the dispatcher that made this attempt
started_at TIMESTAMPTZ When the attempt went out
completed_at TIMESTAMPTZ When the attempt reached a verdict. NULL means the attempt is still in flight
outcome message_outbox_attempt_outcome Verdict of the attempt. NULL means in flight
error_code INTEGER Provider error code returned by this attempt
http_status INTEGER HTTP status returned by this attempt
error_body JSONB Raw provider error body returned by this attempt
provider_message_id TEXT Provider message id returned by this attempt on acceptance
created_at TIMESTAMPTZ Creation timestamp
updated_at TIMESTAMPTZ Last modification timestamp, maintained by trigger

Key Features:

  • A verdict is never rewritten to something more convenient. Resolving an ambiguous intent advances the INTENT to accepted while the attempt keeps its ambiguous verdict — the log records what was known when the call returned, and rewriting it would erase the evidence the send was ever in doubt.
  • UNIQUE (outbox_id, attempt_number) (message_outbox_attempts_number_key) keeps the ordinals total per intent. The counter advances BEFORE the wire call, so an attempt that dies mid-wire still leaves a row for the lease-recovery sweep to find.
  • A row with completed_at IS NULL under a pair's claim_id is the signal recover_stale_message_outbox_leases uses to decide between settling the intent from webhook evidence (accepted or failed) or parking it as ambiguous (a request may have reached the provider), versus returning it to pending (nothing left the process, so retry is safe).
  • The log is append-only, enforced by compare-and-swap. Both settle RPCs close an attempt on (id, outbox_id, claim_id = p_claim_id, completed_at IS NULL), so an attempt of another intent, one made under an earlier claim, or one already settled by the dispatcher or the recovery sweep is never rewritten — a recorded verdict is evidence, not a mutable field. A miss returns attempt_not_found having written nothing at all.
  • Attempt rows cascade away when their intent is pruned; idx_message_outbox_attempts_outbox_id serves the per-intent lookup.
  • Service-role only, with the same four deny-all authenticated policies and no authenticated/anon grants; not in the supabase_realtime publication.