Skip to content

Data

The bot keeps nineteen tables in one Neon Postgres database, the one DATABASE_URL points at (at run time the bot connects through DATABASE_URL_POOLED). Voice audio is never stored, only text. Each section lists a table’s columns, the allowed values of its enum-like fields and the rules the database enforces. Retention says what is cleared. For how the data is written, see Messages and backfill, The reminder engine, Summaries, Ticket prompts, Meeting transcripts, Voice notes and Asking questions.

WhatWhereHow long
Message textmessagesUntil you remove it by hand; a Discord delete empties the text and keeps the row
Search vectors of messagesmessage_embeddingsRemoved with the message text; otherwise until removed by hand
Summary textsummaries30 days, then emptied
Ticket draft textticket_promptsCleared sooner in some cases, otherwise at the first start after 30 days
Meeting transcript textmeeting_transcripts90 days by default, then the row is deleted
Meeting summary and action itemsmeetings, meeting_action_itemsUntil you remove them by hand
Questions asked of the bot and their tool traceask_questions30 days by default, then the row is deleted
Reminder historyreminder_runsUntil you remove it by hand
Members, channels, settingsthe matching sections belowUntil you remove them by hand
Interests a member followspoints_of_interestUntil the member removes them

Retention has the exact rules and settings.

  • Linear is the issue tracker the bot files tickets in.
  • Backfill is the bot reading a channel’s older history, newest to oldest, to store it.
  • Claim means the bot writes a row (or flips its status to claimed) before it does the work, so a retry cannot send it twice. A crash after the claim can lose that one send. Where the bot does retry stuck rows, the table’s section says so.
  • DM is a Discord direct message.
  • ON DELETE cascade means deleting the parent row also deletes this row. set null clears the link instead. No cascade means the database refuses to delete the parent while this row exists.
  • Index types. A GIN index speeds up full-text search; an HNSW index speeds up nearest-vector search. A unique index also forbids duplicates.
  • Discord ids are stored as text. Linear ids and enum-like fields (fields with a short fixed list of allowed values) are varchar, checked in code. There are no database enums.
  • Every table has the three shared columns below. timestamp(3) keeps milliseconds.
  • Shared timestamps have no time zone, so the database TimeZone must stay UTC (the Neon default).
  • updated_at is bumped by the app, so raw SQL updates do not change it. A member sync that changes nothing does not bump it either.
  • The vector extension (pgvector, vector storage and search for Postgres) must exist. Migration 0006 creates it.
  • A search column is a generated tsvector (a word index for full-text search that the database keeps up to date), built with the english full-text configuration. Changing the configuration rewrites the table.
Shared columnTypeMeaning
iduuid, primary keyVersion 7 uuid made by the app, with no database default
created_attimestamp(3), not nullDefault now()
updated_attimestamp(3), not nullDefault now(); set again when the app updates the row

Run bun run db:migrate before you start a new version, see Scripts. The migration files are in the bot repository, src/db/migrations/0000 to 0019.

MigrationChange
0000members
0001channels and messages, with their indexes
0002Replaces the channel index on messages and adds messages_sent_idx
0003member_reminder_preferences, reminder_rules and reminder_runs; channels.linear_team_id and members.setup_nudged_at; the three seeded rules
0004members.left_at; splits the reminder_runs unique rule into the recurring and follow-up indexes
0005reminder_runs.shift_date and reminder_runs_shift_unique
0006The vector extension and message_embeddings
0007summary_configs, summary_recipients and summaries
0008summaries_status_created_idx
0009ticket_prompts
0010members.linear_link_source
0011Friendlier text of the standup rule, see reminder_rules
0012Friendlier text of the follow-up and art-eod rules
0013The six meeting tables: meetings, meeting_transcripts, meeting_participants, meeting_action_items, meeting_excluded_channels, meeting_settings
0014meetings.linear_project_id
0015points_of_interest
0016ask_questions
0017The search columns and GIN indexes on meetings and meeting_transcripts
0018meeting_action_items.cycle_choice
0019ticket_prompts.draft_team_id and meetings.suggested_team_id

If you skip a migration, the bot breaks in these ways:

  • A missing column (0010, 0014, 0017, 0018, 0019) makes every query on that table fail, because the bot reads the whole row. Without 0010 that is members, so nearly everything fails.
  • A missing table behind a clean-up (0007, 0009, 0013, 0016) logs [summaries] startup prune failed: ..., [tickets] startup prune failed: ..., [meetings] prune failed: ... or [ask] prune failed: ... at every start, even with the feature off. The rest of the bot runs.
  • Without 0015, /interests fails and the rest of the bot runs.
  • Without 0011 or 0012 the bot runs with the old reminder wording.

One row per human in the server. Bots get no row. The bot syncs the list at every start and on every join, update and leave. Rows are never deleted: leaving the server only sets left_at, and joining again clears it.

ColumnTypeMeaning
namevarchar(100), not nullLatest server display name, updated when it changes
discord_user_idtext, not null, uniqueDiscord user id
linear_user_idvarchar(64), uniqueThe member’s Linear user id, set by name matching or by /linear-admin link; null when not linked. The unique rule means one Linear user belongs to one member
linear_link_sourcevarchar(8)Who decided the link. See linear_link_source values below
timezonevarchar(64)Time zone name from the IANA list, such as America/New_York. Only valid zone names are saved
work_starttimeStart of the work day
work_endtimeEnd of the work day. Never equal to work_start; the app checks this
setup_nudged_attimestamptz(3)When the one-time setup DM was attempted. Set before sending and kept even if the DM fails. Members who already have a zone and both work times are never nudged
left_attimestamptz(3)When the member left the server; null while present

A member is eligible for channel reminders only when left_at is null and timezone, work_start and work_end are all set.

linear_link_source valueMeaning
nullNobody has decided. The member is open to automatic name matching
autoThe bot linked the member by exact name
manualAn admin linked or unlinked the member with /linear-admin. Name matching never touches the row again

The bot never stores a Linear user’s email or name, only the id. See Linking members to Linear.

Channels and public threads the bot knows about, plus the backfill cursors. Rows are never deleted: when Discord deletes a channel or thread, the row and its team mapping stay and only its messages are emptied.

ColumnTypeMeaning
discord_channel_idtext, not null, uniqueDiscord channel id
parent_channel_idtextSet for threads only
namevarchar(100), not nullChannel name
kindvarchar(16), not nullSee kind values below
newest_message_idtextBackfill cursor: newest message seen
oldest_message_idtextBackfill cursor: oldest message seen
backfill_completeboolean, not null, default falseTrue once history was read back to the start
linear_team_idvarchar(64)Linear team this channel is mapped to for summaries. Set by /summary-admin map-channel, cleared by unmap-channel

kind values: text, announcement, forum (media channels count as forum), thread (public and announcement threads; private threads are not stored). Only the backfill moves the cursors, and they only ever widen. Live events never do. Threads and forum posts count as mapped when their parent channel is mapped to that team.

Messages from the text, announcement, forum and public thread channels the bot can read. Deleting a message keeps the row but empties its text. Rows are never removed.

ColumnTypeMeaning
discord_message_idtext, not null, uniqueDiscord message id
channel_idtext, not nullChannel id; the thread id for thread messages
author_idtext, not nullAuthor’s Discord id
author_namevarchar(100), not nullDisplay name at the time, cut to 100 characters
author_is_botboolean, not nullTrue for bot authors
typesmallint, not nullDiscord message type; only normal messages (0) and replies (19) are stored
contenttext, not null, default emptyMessage text, with NUL characters removed. For a forwarded message with no text of its own, the forwarded text. Empty after a delete
has_attachmentsboolean, not null, default falseTrue when there is at least one attachment
attachment_countsmallint, not null, default 0Attachments, including those on a forwarded message
reply_to_message_idtextSet only for replies
sent_attimestamptz(3), not nullWhen it was sent
edited_attimestamptz(3)When it was last edited
deleted_attimestamptz(3)When it was deleted; the row stays
searchtsvector, generated and storedto_tsvector('english', coalesce(content, '')), kept up to date by the database

Files, embeds and reactions are not stored, only the attachment count.

A delete also empties the rows that quote the message: the draft of a ticket prompt for it, the question of a tag ask that used it, and the trace of an ask that replied to it. Deleting a channel or thread empties every message in it and in its threads. A delete the bot missed while it was offline is caught when the backfill rescans the last 7 days of a channel.

Indexes:

IndexOn
messages_channel_sent_idxchannel_id, sent_at descending, discord_message_id descending
messages_sent_idxsent_at descending
messages_author_sent_idxauthor_id, sent_at descending
messages_reply_to_idxreply_to_message_id, only where it is not null
messages_search_idxGIN on search

Edit rule: a deleted row is never brought back, an older edit never overwrites a newer one, and a row with no change is not rewritten. After the first insert only content, the attachment fields and edited_at change.

One vector per embedded message. Empty unless embeddings are active.

ColumnTypeMeaning
message_iduuid, not null, uniqueThe message, with ON DELETE cascade
embeddinghalfvec(1536), not nullHalf-precision vector
modelvarchar(64), not nullEmbedding model id
content_md5text, not nullMD5 of the message content at the time it was embedded
embedded_attimestamptz(3), not null, default now()When it was embedded

Index: message_embeddings_embedding_hnsw_idx, HNSW on embedding with cosine distance. Only messages that are not deleted, not written by a bot, not blank and of a stored type are embedded. Such a message needs embedding when it has no row, its content_md5 no longer matches md5(content), or the stored model differs from the current one. Deleting a message, or editing it to blank text, removes its vector.

ColumnTypeMeaning
namevarchar(50), not null, uniqueFormat ^[a-z0-9-]{1,50}$
kindvarchar(16), not nullSee kind values below
channel_idtextChannel to watch; null for follow-ups and unset rules
anchorvarchar(16)See anchor values below
grace_minutesintegerMinutes to wait after the anchor (start or end of the member’s work day) before the reminder is due. Null counts as 0. A rule made with /reminder-rules create and no grace gets 120 for a start-of-day rule and 0 for an end-of-day rule
require_attachmentboolean, not null, default falseNeeds an attachment to count as posted
messagetext, not nullMessage template with placeholders
prompttextNot read by anything today
linear_team_idvarchar(64)Not read by anything today
enabledboolean, not null, default trueRules are disabled, never deleted

kind values: channel_watch, follow_up. anchor values: start_of_day, end_of_day. The app lets a follow-up rule change only its message. Check reminder_rules_grace_minutes_check: grace_minutes is between 0 and 1440 (null passes).

Migration 0003 adds three rules and sets no channel on any of them.

RuleKindAnchorGraceAttachmentEnabledChannel
standupchannel_watchstart_of_day120noyesnot set
art-eodchannel_watchend_of_day0yesnonot set
follow-upfollow_upnonenonenoyesnone

Migrations 0011 and 0012 reword three rules, each only where the message still holds its seeded text, and bump updated_at on that row. A rule an admin has reworded is left alone. The default message of a rule created with /reminder-rules create is the new standup text.

  • standup (0011), from Morning! You have not posted in {channel} yet. {previous} to {greeting} {name}, looks like you haven't posted in {channel} yet. This is a friendly reminder to do so whenever you get the chance. {previous}.
  • follow-up (0012), from Reminder to follow up: {link} {note} to {greeting} {name}, this is a friendly reminder to follow up on {link}. {note}.
  • art-eod (0012), from End of day: please post a short summary and an image of what you worked on in {channel}. {previous} to {greeting} {name}, looks like you haven't posted your end-of-day update in {channel} yet. This is a friendly reminder to share a short summary and an image of what you worked on whenever you get the chance. {previous}.

The placeholders {greeting} and {name} are filled in by the bot when it sends, see Reminder rules.

A watch rule with no channel is ignored. On each check of the reminder engine (a tick), if standup has no channel, the bot sets it once to the text or announcement channel named stand-up (any letter case, hyphen required, so standup does not match) that it stored first, and never overwrites a channel that is set. art-eod gets no such help.

A member’s personal switch and time for one rule.

ColumnTypeMeaning
member_iduuid, not nullThe member, with ON DELETE cascade
rule_iduuid, not nullThe rule, with ON DELETE cascade
enabledboolean, not null, default trueFalse means the member turned this rule off
custom_timetimeThe exact time the reminder fires, with no wait added; null follows work hours

Unique constraint member_reminder_preferences_member_rule_unique on (member_id, rule_id). No row means the rule is on.

One row per reminder the bot sent, tried or scheduled.

ColumnTypeMeaning
rule_iduuid, not nullThe rule; no cascade
member_iduuid, not nullThe member; no cascade
scheduled_fortimestamptz(3), not nullThe due time
statusvarchar(16), not nullSee status values below
sent_attimestamptz(3)Set only when the status is sent
failure_reasontextWhy it failed or was skipped
related_message_idtextFollow-ups only: channelId/messageId. Null for channel reminders
notevarchar(200)Follow-ups only: the optional note
shift_datedateChannel reminders only: the member’s local shift day

status values: scheduled, claimed, sent, failed, skipped.

LifecycleSteps
Channel reminderInserted as claimed, then sent or failed
Follow-upscheduled, then claimed, then sent, failed or skipped. A claimed row not updated for 10 minutes goes back to scheduled, and the bot puts unprocessed claimed rows back when it shuts down.

Failure and skip reasons: dm_closed, discord_<code> for any other Discord error, a shortened error text of up to 300 characters and internal_error, which is written when anything fails after a run is claimed and before the result is recorded. Follow-ups can also end with rule_not_found, member_not_found, not_allowlisted (skipped; the member is not on REMINDER_ALLOWLIST) and left_guild (skipped).

A channel reminder that a crash leaves in claimed is never retried, and the shift index stops a second one for that shift day. A follow-up is the only run the bot picks up again.

Indexes that enforce the guarantees:

IndexOnPurpose
reminder_runs_recurring_uniquerule_id, member_id, scheduled_for, where related_message_id is nullLets the bot claim a channel reminder only once
reminder_runs_follow_up_uniquerule_id, member_id, related_message_id, scheduled_for, where related_message_id is not nullThe same follow-up is never stored twice
reminder_runs_shift_uniquerule_id, member_id, shift_dateAt most one reminder per rule, member and shift day, so changing your hours after being reminded earns no second one. Follow-ups have no shift date, so they never clash.
reminder_runs_due_idxscheduled_for, where status is scheduledFinds due follow-ups quickly

One row per summary. Rows are never deleted, only disabled.

ColumnTypeMeaning
namevarchar(50), not null, uniqueFormat ^[a-z0-9-]{1,50}$
kindvarchar(16), not nullSee kind values below
descriptionvarchar(200)Given to the model as context
linear_team_idvarchar(64)Linear team. Required when kind is project
linear_project_idvarchar(64)Limits Linear tickets to one project; null means the whole team
prompttext, not nullWhat to emphasise. Empty means none. Only the form limits it to 2000 characters
cadencevarchar(16), not nullSee cadence values below
weekdaysmallint1 (Monday) to 7 (Sunday). Required for weekly
send_timetime, not null, default 09:00:00Read in each recipient’s own timezone
ephemeralboolean, not null, default trueStored only. Nothing sets it other than the default, so it is true on every row, and no code reads it
review_status_namestext[], not null, default {In Review}Linear state names that count as in review. No command changes it
review_flag_daysinteger, not null, default 7Tickets in review this many days or more are flagged. No command changes it
enabledboolean, not null, default trueSummaries are disabled, never deleted

kind values: project, person. person rows are created by /interests, one per member, named interests-<discord user id>, with no linear_team_id, an empty prompt, a daily cadence until the member changes it and the member as the only recipient. They are never deleted, only disabled, and /summary-admin and /summary now do not list or change them. The prefix interests- is reserved: a project summary cannot use it. cadence values: daily, weekly, on_demand.

Checks the database enforces:

CheckRule
summary_configs_weekly_weekday_checkcadence is not weekly, or weekday is set
summary_configs_weekday_range_checkweekday is null or between 1 and 7
summary_configs_project_team_checkkind is not project, or linear_team_id is set
summary_configs_review_flag_days_checkreview_flag_days is 1 or more

Unique constraint: summary_configs_name_unique on name. The database allows a weekday on a non-weekly cadence; the bot does not. No migration adds any summary.

Who gets a summary and may run /summary now for it. Each row is one person or one role.

ColumnTypeMeaning
config_iduuid, not nullThe summary, with ON DELETE cascade
discord_user_idtextA person’s Discord id
discord_role_idtextA role’s Discord id. Everyone who holds it when a summary is sent is a recipient

Check summary_recipients_one_target_check: exactly one of the two ids is set.

IndexOnPurpose
summary_recipients_user_uniqueconfig_id, discord_user_id, where the user id is not nullA person is added to a summary once
summary_recipients_role_uniqueconfig_id, discord_role_id, where the role id is not nullA role is added to a summary once

One row per summary the bot tried to write for one person. Rows are kept, except a claim released after a short Discord lookup failure. After 30 days the text in content is cleared and the rest of the row stays, see Retention.

ColumnTypeMeaning
config_iduuid, not nullThe summary; no cascade
member_iduuid, not nullThe person it was written for; no cascade
period_starttimestamptz(3), not nullStart of the period it covers
period_endtimestamptz(3), not nullEnd of the period it covers
contenttext, not nullThe full summary text. Empty for failed and skipped rows, and after 30 days
statusvarchar(16), not nullSee status values below
delivered_attimestamptz(3)Set only when the status is sent
failure_reasontextWhy it failed or was skipped
triggervarchar(16), not nullSee trigger values below

status values: claimed, sent, failed, skipped. trigger values: scheduled, on_demand.

LifecycleSteps
ScheduledInserted as claimed, then sent, failed or skipped. A row still claimed 30 minutes after its last update becomes failed with reason interrupted. If a Discord lookup fails for a short while, the claim is deleted so the next tick can try again.
On demandInserted as claimed, then sent or failed. A crash can leave the row claimed for good: only scheduled rows are reclaimed

Failure and skip reasons: no_visible_channels (skipped), no_matches and no_interests (skipped, personal digests only), interrupted, shutdown, ai_not_configured, ai_<ErrorName> such as ai_AiOutputError, generation_failed, dm_closed, discord_<code>, reply_failed (on-demand only), or a shortened error text of up to 300 characters.

IndexOnPurpose
summaries_scheduled_uniqueconfig_id, member_id, period_end, where trigger is scheduledLets the bot claim a scheduled summary only once. On-demand rows never clash
summaries_member_sent_idxconfig_id, member_id, period_end, where status is sentFinds a person’s previous sent summary
summaries_status_created_idxstatus, created_atCounts sent and failed summaries in the last 24 hours for the daily limit

Because of the first index, a failed or skipped scheduled summary is not tried again for the same period.

What a member follows with /interests. Rows belong to one member and are hard-deleted when removed or when the member row is deleted.

ColumnTypeMeaning
owner_member_iduuid, not nullThe member, with ON DELETE cascade
person_discord_user_idtextThe followed person’s Discord id
topicvarchar(200)Free-text topic
channel_idstext[], not null, default {}Up to 3 channel ids
promptvarchar(500)Extra note for the digest
CheckRule
points_of_interest_subject_checkA person, a topic or at least one channel is set
points_of_interest_channels_max_checkAt most 3 channels

Index points_of_interest_owner_idx on owner_member_id. The cap of 10 per member is enforced by the bot, not the database.

A digest that matches nothing is stored in summaries as skipped. no_matches rows count toward the daily generation limit.

One row per message the bot acted on after checking it for a feature idea: a prompt sent, a prompt that failed, or a match to an existing Linear ticket. Rows are never deleted; only the draft text and the suggested team are cleared.

ColumnTypeMeaning
discord_message_idtext, not null, uniqueThe message that was checked. One row per message
channel_idtext, not nullChannel id of that message
author_idtext, not nullThe author’s Discord id
statusvarchar(16), not null, no defaultSee status values below
draft_titlevarchar(200)The AI-written title, or the text the author last typed. Null once cleared
draft_descriptiontextThe AI-written description, or the text the author last typed. Null once cleared. The app limits it to 4000 characters
draft_team_idvarchar(64)The Linear team id the AI suggested, when the author was offered two or more teams and it clearly named one. The form opens with that team selected. Null when there is no suggestion or once cleared
linear_issue_idvarchar(64)The Linear issue id. The bot picks a random uuid when the author submits the form and creates the issue in Linear under that id. It stays set after a failed create so a retry reuses it
linear_issue_identifiervarchar(32)The Linear identifier. Set on created and duplicate
linear_issue_urltextThe Linear link. Set on created and duplicate
failure_reasontextWhy a prompt failed

status values: prompted, creating, created, dismissed, failed, duplicate. The database does not check them.

StatusMeaning
promptedInserted before the DM is sent. The author has not decided, or a failed create was released
creatingThe author submitted the form and the ticket is being filed
createdThe Linear ticket exists
dismissedThe author pressed No thanks
failedThe DM could not be sent, or the message was deleted during the check
duplicateThe bot found a matching Linear ticket and sent no DM

Lifecycle:

StepChange
Prompt claimedInserted as prompted. The insert does nothing if the message already has a row, so each message is prompted at most once
DM not delivered, or message deleted mid-checkprompted to failed. failure_reason holds dm_closed, discord_<code>, message_deleted or an error text of up to 300 characters
No thanksprompted to dismissed
Form submittedprompted to creating. A creating row whose updated_at is more than 2 minutes old can be claimed again, for example after a crash
Ticket filedcreating to created
Create failed, or message deleted before filingcreating back to prompted
Match foundInserted directly as duplicate

created, dismissed, failed and duplicate are final. A stale creating row is reclaimed with the same linear_issue_id, so a retry never makes a second Linear ticket.

Draft text and the suggested team are cleared when the ticket is created, the prompt is dismissed or fails, the source message or its channel is deleted, and 30 days after created_at (see Retention). The status, ids and failure reason stay.

Indexes and constraints:

IndexOnPurpose
ticket_prompts_discord_message_id_uniquediscord_message_idA message is checked and prompted at most once
ticket_prompts_author_created_idxauthor_id, created_atCounts one author’s prompts in the last 24 hours for the daily limit

Rows with status duplicate do not count toward the daily limit. All other statuses do. There are no foreign keys and no check constraints.

One row per uploaded transcript or voice meeting that the bot claimed. Rows are never deleted. Only the transcript text, in its own table, is deleted.

ColumnTypeMeaning
titletext, not nullThe meeting title. Empty until the summary is saved
held_attimestamp(3)The meeting date, only when the transcript stated it. Asking falls back to created_at when it is empty
linear_team_idvarchar(64)The Linear team the meeting named, when exactly one public team matched, or the team a first approver picked when it named none. Used for every ticket from this meeting that the approver is offered that team for
suggested_team_idvarchar(64)The public Linear team the AI suggested, only when no stated team resolved and the meeting has new work to file. Never copied into linear_team_id and never files anything: it only puts that team first in the team picker. Null when there is no suggestion. Not cleared
linear_project_idvarchar(64)The Linear project the meeting named, when exactly one project of the resolved team matched by exact name. Tickets from this meeting are filed into it only while it is still one of the chosen team’s projects; otherwise they are filed without a project
decision_summarytext, not null, default emptyThe decisions, one per line
discussion_summarytext, not null, default emptyThe discussion points, one per line
uploader_idtext, not nullThe Discord id of the member who uploaded the file, or who started the voice notes. A takeover by a later upload of the same file replaces it
source_message_idtext, uniqueThe Discord message that held the file. A takeover replaces it with the new upload’s message. Empty for a voice meeting, which the unique key allows many times
sourcevarchar(16), not null, default uploadupload or voice. The database does not check them
voice_channel_idtextThe voice channel of a voice meeting. Empty for an upload
started_at, ended_attimestamp(3)When a voice meeting started and ended. Empty for an upload
channel_idtext, not nullWhere the summary was or will be posted. For an upload, the channel of its message. For a voice meeting, the shared summary channel or the voice channel’s own text chat
summary_message_idstext[], not null, default {}The ids of the bot’s posts in the channel, saved one by one as each is sent. The first one is linked from the direct messages. A re-run deletes these posts and starts the list again
content_sha256varchar(64), not null, uniqueSHA-256 of the file’s bytes, or of message:<id> for a file refused before it was read. For a voice meeting, the SHA-256 of the transcript, the voice channel id and the start time
statusvarchar(16), not nullprocessing, posted or failed. The database does not check them
searchtsvector, generated and storedto_tsvector of the title, decisions and discussion, kept up to date by the database
failure_reasontextWhy an upload failed: unsupported, too_large, not_text, empty, fetch_failed, ai_unavailable, daily_cap, uploader_cap, no_channel, recovery_failed, aborted or generic. aborted on a voice meeting means it was saved at shutdown and is finished at the next start

Index: meetings_search_idx, GIN on search.

  • The two unique keys make an upload idempotent: a repeat of the file or of the message finds the existing row.
  • A processing run refreshes updated_at every minute.
  • A failed row, or a processing row not updated for 10 minutes, is taken over with a compare-and-set when the same file is uploaded again. The takeover sets uploader_id, source_message_id and channel_id to the new upload, and saving the draft again replaces the meeting’s participants and action items.
  • A posted row whose items still have unsent messages gets them sent when the same file is uploaded again, 2 minutes after its last update.
  • At the next start, a voice meeting that has a saved transcript and is failed with aborted, or processing and not updated for 10 minutes, is finished once. When Linear is configured, a posted voice meeting from the last 7 days whose action item DMs never went out gets them sent. See Restart and recovery.

The transcript text, kept apart so it can be deleted without touching the summary.

ColumnTypeMeaning
meeting_iduuid, not null, uniqueThe meeting, with ON DELETE cascade. One transcript per meeting
transcripttext, not nullThe text after reading the file, at most 400,000 characters. For a .vtt or .srt file, the cleaned text. For a voice meeting, the Display Name: words lines
transcription_modelstext[], not null, default {}The speech-to-text models that answered for a voice meeting, such as @playsafe/nvidia_parakeet_0_6B_v3_cluster_1. Empty for an upload. Deleted with the text
searchtsvector, generated and storedto_tsvector of transcript, kept up to date by the database

Index: meeting_transcripts_search_idx, GIN on search.

Rows are deleted by age, see Retention. Saving the transcript again after a failed run resets created_at.

The attendee list of a meeting. For an upload, the names as the model read them, at most 50. For a voice meeting, the people who spoke, by their display name. Saving the draft again replaces the list.

ColumnTypeMeaning
meeting_iduuid, not nullThe meeting, with ON DELETE cascade
display_nametext, not nullThe name as said in the transcript, or the speaker’s display name for a voice meeting
member_iduuidThe member the name matched (for a voice meeting, the member who spoke), or null. ON DELETE set null

One row per action item, up to 15 per meeting. Saving the draft again after a failed run replaces them.

ColumnTypeMeaning
meeting_iduuid, not nullThe meeting, with ON DELETE cascade
positioninteger, not nullThe order in the summary
titletext, not nullThe item title
descriptiontext, not null, default emptyThe item description. Becomes the ticket description
owner_nametextThe owner as said in the meeting
assignee_member_iduuidThe member the owner name matched, or null when none or several matched. ON DELETE set null
statusvarchar(16), not null, default pendingSee below
dm_statusvarchar(16), not null, default pendingpending, sent or failed
kindvarchar(16), not null, default new_worknew_work or update_existing. An update changes a ticket that already exists instead of creating one
cycle_choicevarchar(8), not null, default nonenone, current or next: the cycle picked in the direct message for the ticket this item files. The database does not check them. Updates never move a ticket into a cycle
update_kindvarchar(16)status or comment for an update, null otherwise
target_statusvarchar(16)For a status update: backlog, todo, in_progress or in_review. There is no value for done or canceled
target_issue_identifier, target_issue_url, target_issue_titlevarchar(32), text, varchar(255)The existing ticket an update applies to. Null when none was found, and then the item is shown as a note and cannot be selected
possible_issue_identifier, possible_issue_url, possible_issue_titlevarchar(32), text, varchar(255)A ticket that may cover the item. Shown only in the direct message. A ticket that clearly covers it is shown in the channel post and is not stored
linear_issue_idvarchar(64)The Linear issue id, fixed when the item is first claimed. It stays set after a failed create so a retry reuses it
linear_issue_identifier, linear_issue_urlvarchar(32), textThe ticket the bot created, or for an update the ticket it changed. Set on created. An update never sets linear_issue_id
failure_reasontextWhy a direct message failed: not_allowlisted, dm_closed, discord_<code> or send_failed
status valueMeaning
pendingOffered, not selected
approvedSelected in the drop-down
rejectedDismissed with Dismiss or Dismiss all
creatingClaimed, and being filed or applied. A creating row whose updated_at is more than 2 minutes old can be claimed again
createdThe Linear ticket exists, or the update was applied

Indexes: meeting_action_items_meeting_position_idx on meeting_id, position, and meeting_action_items_assignee_status_idx on assignee_member_id, status. A retry reuses linear_issue_id, so it never makes a second Linear ticket. There are no check constraints.

Deleting the upload message or its channel changes none of these rows.

Voice and stage channels where members may not start voice notes. Rows are removed by /notes-admin include.

ColumnTypeMeaning
channel_idtext, not null, uniqueThe Discord id of the blocked channel
added_bytext, not nullThe Discord id of the admin who blocked it

There are no foreign keys. A deleted channel keeps its row.

One row per server, written by /notes-admin summary-channel set.

ColumnTypeMeaning
guild_idtext, not null, uniqueThe server id
summary_channel_namevarchar(100)The summary channel name an admin chose. Empty means the MEETING_CHANNEL_NAME default applies
updated_bytext, not nullThe Discord id of the admin who last set it

The name is read once at startup and kept in memory. The command stores the value first and then updates the running bot.

One row per question claimed with /ask or a tag, created as pending at the claim. Rows hold the question, the tool arguments and the ids the tools returned, no message text and not the answer. Rows are deleted by age, see Retention.

The daily limits count every row from the last 24 hours. A crash can leave a row pending: it counts and is never retried.

ColumnTypeMeaning
member_iduuid, not nullThe asker, with ON DELETE cascade
discord_user_idtext, not nullThe asker’s Discord id
channel_idtextThe channel of the question. Null when it was not in a channel
viavarchar(16), not nullcommand or tag
source_message_idtextThe tagging message, for tag rows
context_message_idtextThe message the tagging message replied to, if any
questiontext, not nullThe question, at most 500 characters. Blank once the tagging message is deleted, or its channel or thread is, whatever the status. An edit does not blank it
statusvarchar(16), not nullpending, answered, no_answer or failed
failure_reasonvarchar(64)The error class name, scope_unavailable or no_guild. Set only when the status is failed
tracejsonb, not null, default []The model’s tool calls, at most 30. Each entry holds tool, args (cut to 500 characters), resultIds (at most 200) and resultCount. Emptied when the tagging message, its channel or the replied-to message is deleted
cited_refstext[], not null, default {}The sources the answer cited, as discord:<id> or meeting:<id>
modelvarchar(64)The model that answered
latency_msintegerMilliseconds the answer took
input_tokens, output_tokensintegerToken counts of the answer call
feedbackvarchar(8)up or down
answered_attimestamp(3)When the answer was finished

Indexes: ask_questions_member_created_idx on member_id, created_at; ask_questions_created_idx on created_at; ask_questions_source_message_idx on source_message_id.

The bot clears or deletes only these things. Everything else stays until you remove it by hand, including messages, vectors, reminder runs, ticket prompt rows, meetings and their action items.

WhatRuleWhen it runs
summaries.contentEmptied 30 days after created_at. Rows still claimed and rows already empty are skipped. The row stays. The 30 days are fixedAt every start, even with summaries off, and about once a day while the summary scheduler runs
ticket_prompts draft fieldsdraft_title, draft_description and draft_team_id set to null 30 days after created_at. The 30 days are fixedAt every start only, even with ticket prompts off. Never while the bot keeps running
meeting_transcripts rowsDeleted after MEETING_TRANSCRIPT_RETENTION_DAYS days from created_at. Whole number from 1 to 3650, default 90At every start, then once a day, even with the feature off
ask_questions rowsDeleted after ASK_LOG_RETENTION_DAYS days from created_at. Whole number from 1 to 3650, default 30At every start, then once a day, even with asking off

A failed clean-up logs a line like those under Migrations and the bot carries on.