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_senderon (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;createConversationnow upserts on this key (ON CONFLICT DO NOTHING, then re-reads the winner).channelis NOT NULL so the key is total. (Per the domain model(project, user)is 1:1 withproject_relationship, soproject_relationship_idis not part of the key — it would be redundant.) - Partial unique index (
idx_conversation_one_current_per_user_sender) ensures only onecurrentconversation per user/channel/channel_identifier/notification_sender - CASCADE DELETE on
project_idforeign 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_typecategory, not from any client-supplied value. The project'sproject_typecategory id is looked up and matched againstnotification_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 noproject_typecategory 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
currentconversation 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 tocurrent; 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: active → closed → summarized. 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_idcreates parent-child relationships for message replies (e.g., email threads)root_message_iddenormalized field pointing to the root of the thread (auto-populated via trigger)external_idprevents duplicate processing of messages (e.g., same email processed twice)- Trigger automatically sets
root_message_idby recursively traversingparent_idchain - 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_idlinks messages to their approval workfloworigin_message_idlinks UI thread messages to their source WhatsApp/Email inbound message (for queries/replies via external channels)v_approval_messagesview filters by source='Approval Message' to show only UI thread- Payment Message Architecture (parallels Approval Message):
payment_idlinks messages to a payment (typed FK, parallelsapproval_request_idfor 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 stamppayment_idparent_iddistinguishes the root request (outbound) from threaded replies (inbound)metadata.outcomecarries reply sub-types (uploaded,missing_justification) so the chat surface can branch without joining a sibling tablev_payment_messagesview exposes the full thread with a derivedsender_name(entity-first, user fallback) and surfacessource+metadataso 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:
pending→sending(claimed by a dispatcher) →accepted|failed|ambiguous. A retryable failure under the attempt ceiling returnssending→pending; at the ceiling it becomesfailed. Anambiguousintent leaves that state two ways:resolve_message_outbox_ambiguoussettles it on a status webhook (acceptedon a delivery,failedon a providerfailedstatus — 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_ambiguouson a row stillsendingrecords EVIDENCE only — theprovider_message_idfor an acceptance, a{"source":"status_webhook","status":"failed"}marker inlast_errorfor 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_attemptchecks it before parking anambiguousclassification, returningaccepted_by_evidence(with the wamid, so persistence runs immediately) orfailed_by_evidence; recover_stale_message_outbox_leaseschecks the same two markers when the dispatcher died mid-wire, settling toacceptedorfailedinstead ofambiguous;- the sweep re-drives the resolution for any already-parked
ambiguousrow that carries aprovider_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.
ambiguousis 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, andsender_key/recipient_keyare 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_attracks 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 — themessagesrow and delivery update on an accepted row, the delivery failure mark on a failed one. Both repair scans filterpersisted_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 stillpending, which is exactly the state the repair exists to fix.- Indexes:
idx_message_outbox_pending_head(partial onstate = '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, onnext_attempt_at) serves the due-scan;idx_message_outbox_sending_pair(partial onstate = '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 onstate = 'accepted' AND persisted_at IS NULL, onupdated_at) serves the persistence-retry scan andidx_message_outbox_failed_unpersisted(partial onstate = 'failed' AND persisted_at IS NULL, onupdated_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 onnotification_delivery_id. - Service-role only. RLS is enabled with four deny-all
authenticatedpolicies (SELECT/INSERT/UPDATE/DELETE, allfalse), andauthenticated/anonreceive 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 thesupabase_realtimepublication. - Automatic
updated_attracking 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_outboxcreates 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
sendingrow belongs to the recovery sweep, not the claim path.recover_stale_message_outbox_leasesmatches that row on the PAIR'sclaim_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 nomessage_outboxrows 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 apendingorambiguousone the prune deliberately kept) keeps its pacing cursor and serialisation lock. - Index:
idx_message_outbox_pairs_lease_expires_at(partial onclaim_id IS NOT NULL) serves the expired-lease sweep. - Service-role only, with the same four deny-all
authenticatedpolicies and no authenticated/anon grants; not in thesupabase_realtimepublication.
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
acceptedwhile the attempt keeps itsambiguousverdict — 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 NULLunder a pair'sclaim_idis the signalrecover_stale_message_outbox_leasesuses to decide between settling the intent from webhook evidence (acceptedorfailed) or parking it asambiguous(a request may have reached the provider), versus returning it topending(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 returnsattempt_not_foundhaving written nothing at all. - Attempt rows cascade away when their intent is pruned;
idx_message_outbox_attempts_outbox_idserves the per-intent lookup. - Service-role only, with the same four deny-all
authenticatedpolicies and no authenticated/anon grants; not in thesupabase_realtimepublication.