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.
What is stored, and for how long
Section titled “What is stored, and for how long”| What | Where | How long |
|---|---|---|
| Message text | messages | Until you remove it by hand; a Discord delete empties the text and keeps the row |
| Search vectors of messages | message_embeddings | Removed with the message text; otherwise until removed by hand |
| Summary text | summaries | 30 days, then emptied |
| Ticket draft text | ticket_prompts | Cleared sooner in some cases, otherwise at the first start after 30 days |
| Meeting transcript text | meeting_transcripts | 90 days by default, then the row is deleted |
| Meeting summary and action items | meetings, meeting_action_items | Until you remove them by hand |
| Questions asked of the bot and their tool trace | ask_questions | 30 days by default, then the row is deleted |
| Reminder history | reminder_runs | Until you remove it by hand |
| Members, channels, settings | the matching sections below | Until you remove them by hand |
| Interests a member follows | points_of_interest | Until 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 cascademeans deleting the parent row also deletes this row.set nullclears 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.
Conventions
Section titled “Conventions”- Discord ids are stored as
text. Linear ids and enum-like fields (fields with a short fixed list of allowed values) arevarchar, 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
TimeZonemust stay UTC (the Neon default). updated_atis bumped by the app, so raw SQL updates do not change it. A member sync that changes nothing does not bump it either.- The
vectorextension (pgvector, vector storage and search for Postgres) must exist. Migration 0006 creates it. - A
searchcolumn is a generatedtsvector(a word index for full-text search that the database keeps up to date), built with theenglishfull-text configuration. Changing the configuration rewrites the table.
| Shared column | Type | Meaning |
|---|---|---|
id | uuid, primary key | Version 7 uuid made by the app, with no database default |
created_at | timestamp(3), not null | Default now() |
updated_at | timestamp(3), not null | Default now(); set again when the app updates the row |
Migrations
Section titled “Migrations”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.
| Migration | Change |
|---|---|
| 0000 | members |
| 0001 | channels and messages, with their indexes |
| 0002 | Replaces the channel index on messages and adds messages_sent_idx |
| 0003 | member_reminder_preferences, reminder_rules and reminder_runs; channels.linear_team_id and members.setup_nudged_at; the three seeded rules |
| 0004 | members.left_at; splits the reminder_runs unique rule into the recurring and follow-up indexes |
| 0005 | reminder_runs.shift_date and reminder_runs_shift_unique |
| 0006 | The vector extension and message_embeddings |
| 0007 | summary_configs, summary_recipients and summaries |
| 0008 | summaries_status_created_idx |
| 0009 | ticket_prompts |
| 0010 | members.linear_link_source |
| 0011 | Friendlier text of the standup rule, see reminder_rules |
| 0012 | Friendlier text of the follow-up and art-eod rules |
| 0013 | The six meeting tables: meetings, meeting_transcripts, meeting_participants, meeting_action_items, meeting_excluded_channels, meeting_settings |
| 0014 | meetings.linear_project_id |
| 0015 | points_of_interest |
| 0016 | ask_questions |
| 0017 | The search columns and GIN indexes on meetings and meeting_transcripts |
| 0018 | meeting_action_items.cycle_choice |
| 0019 | ticket_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,
/interestsfails and the rest of the bot runs. - Without 0011 or 0012 the bot runs with the old reminder wording.
members
Section titled “members”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.
| Column | Type | Meaning |
|---|---|---|
name | varchar(100), not null | Latest server display name, updated when it changes |
discord_user_id | text, not null, unique | Discord user id |
linear_user_id | varchar(64), unique | The 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_source | varchar(8) | Who decided the link. See linear_link_source values below |
timezone | varchar(64) | Time zone name from the IANA list, such as America/New_York. Only valid zone names are saved |
work_start | time | Start of the work day |
work_end | time | End of the work day. Never equal to work_start; the app checks this |
setup_nudged_at | timestamptz(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_at | timestamptz(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 value | Meaning |
|---|---|
| null | Nobody has decided. The member is open to automatic name matching |
auto | The bot linked the member by exact name |
manual | An 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
Section titled “channels”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.
| Column | Type | Meaning |
|---|---|---|
discord_channel_id | text, not null, unique | Discord channel id |
parent_channel_id | text | Set for threads only |
name | varchar(100), not null | Channel name |
kind | varchar(16), not null | See kind values below |
newest_message_id | text | Backfill cursor: newest message seen |
oldest_message_id | text | Backfill cursor: oldest message seen |
backfill_complete | boolean, not null, default false | True once history was read back to the start |
linear_team_id | varchar(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
Section titled “messages”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.
| Column | Type | Meaning |
|---|---|---|
discord_message_id | text, not null, unique | Discord message id |
channel_id | text, not null | Channel id; the thread id for thread messages |
author_id | text, not null | Author’s Discord id |
author_name | varchar(100), not null | Display name at the time, cut to 100 characters |
author_is_bot | boolean, not null | True for bot authors |
type | smallint, not null | Discord message type; only normal messages (0) and replies (19) are stored |
content | text, not null, default empty | Message text, with NUL characters removed. For a forwarded message with no text of its own, the forwarded text. Empty after a delete |
has_attachments | boolean, not null, default false | True when there is at least one attachment |
attachment_count | smallint, not null, default 0 | Attachments, including those on a forwarded message |
reply_to_message_id | text | Set only for replies |
sent_at | timestamptz(3), not null | When it was sent |
edited_at | timestamptz(3) | When it was last edited |
deleted_at | timestamptz(3) | When it was deleted; the row stays |
search | tsvector, generated and stored | to_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:
| Index | On |
|---|---|
messages_channel_sent_idx | channel_id, sent_at descending, discord_message_id descending |
messages_sent_idx | sent_at descending |
messages_author_sent_idx | author_id, sent_at descending |
messages_reply_to_idx | reply_to_message_id, only where it is not null |
messages_search_idx | GIN 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.
message_embeddings
Section titled “message_embeddings”One vector per embedded message. Empty unless embeddings are active.
| Column | Type | Meaning |
|---|---|---|
message_id | uuid, not null, unique | The message, with ON DELETE cascade |
embedding | halfvec(1536), not null | Half-precision vector |
model | varchar(64), not null | Embedding model id |
content_md5 | text, not null | MD5 of the message content at the time it was embedded |
embedded_at | timestamptz(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.
reminder_rules
Section titled “reminder_rules”| Column | Type | Meaning |
|---|---|---|
name | varchar(50), not null, unique | Format ^[a-z0-9-]{1,50}$ |
kind | varchar(16), not null | See kind values below |
channel_id | text | Channel to watch; null for follow-ups and unset rules |
anchor | varchar(16) | See anchor values below |
grace_minutes | integer | Minutes 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_attachment | boolean, not null, default false | Needs an attachment to count as posted |
message | text, not null | Message template with placeholders |
prompt | text | Not read by anything today |
linear_team_id | varchar(64) | Not read by anything today |
enabled | boolean, not null, default true | Rules 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.
| Rule | Kind | Anchor | Grace | Attachment | Enabled | Channel |
|---|---|---|---|---|---|---|
standup | channel_watch | start_of_day | 120 | no | yes | not set |
art-eod | channel_watch | end_of_day | 0 | yes | no | not set |
follow-up | follow_up | none | none | no | yes | none |
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), fromMorning! 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), fromReminder to follow up: {link} {note}to{greeting} {name}, this is a friendly reminder to follow up on {link}. {note}.art-eod(0012), fromEnd 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.
member_reminder_preferences
Section titled “member_reminder_preferences”A member’s personal switch and time for one rule.
| Column | Type | Meaning |
|---|---|---|
member_id | uuid, not null | The member, with ON DELETE cascade |
rule_id | uuid, not null | The rule, with ON DELETE cascade |
enabled | boolean, not null, default true | False means the member turned this rule off |
custom_time | time | The 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.
reminder_runs
Section titled “reminder_runs”One row per reminder the bot sent, tried or scheduled.
| Column | Type | Meaning |
|---|---|---|
rule_id | uuid, not null | The rule; no cascade |
member_id | uuid, not null | The member; no cascade |
scheduled_for | timestamptz(3), not null | The due time |
status | varchar(16), not null | See status values below |
sent_at | timestamptz(3) | Set only when the status is sent |
failure_reason | text | Why it failed or was skipped |
related_message_id | text | Follow-ups only: channelId/messageId. Null for channel reminders |
note | varchar(200) | Follow-ups only: the optional note |
shift_date | date | Channel reminders only: the member’s local shift day |
status values: scheduled, claimed, sent, failed, skipped.
| Lifecycle | Steps |
|---|---|
| Channel reminder | Inserted as claimed, then sent or failed |
| Follow-up | scheduled, 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:
| Index | On | Purpose |
|---|---|---|
reminder_runs_recurring_unique | rule_id, member_id, scheduled_for, where related_message_id is null | Lets the bot claim a channel reminder only once |
reminder_runs_follow_up_unique | rule_id, member_id, related_message_id, scheduled_for, where related_message_id is not null | The same follow-up is never stored twice |
reminder_runs_shift_unique | rule_id, member_id, shift_date | At 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_idx | scheduled_for, where status is scheduled | Finds due follow-ups quickly |
summary_configs
Section titled “summary_configs”One row per summary. Rows are never deleted, only disabled.
| Column | Type | Meaning |
|---|---|---|
name | varchar(50), not null, unique | Format ^[a-z0-9-]{1,50}$ |
kind | varchar(16), not null | See kind values below |
description | varchar(200) | Given to the model as context |
linear_team_id | varchar(64) | Linear team. Required when kind is project |
linear_project_id | varchar(64) | Limits Linear tickets to one project; null means the whole team |
prompt | text, not null | What to emphasise. Empty means none. Only the form limits it to 2000 characters |
cadence | varchar(16), not null | See cadence values below |
weekday | smallint | 1 (Monday) to 7 (Sunday). Required for weekly |
send_time | time, not null, default 09:00:00 | Read in each recipient’s own timezone |
ephemeral | boolean, not null, default true | Stored only. Nothing sets it other than the default, so it is true on every row, and no code reads it |
review_status_names | text[], not null, default {In Review} | Linear state names that count as in review. No command changes it |
review_flag_days | integer, not null, default 7 | Tickets in review this many days or more are flagged. No command changes it |
enabled | boolean, not null, default true | Summaries 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:
| Check | Rule |
|---|---|
summary_configs_weekly_weekday_check | cadence is not weekly, or weekday is set |
summary_configs_weekday_range_check | weekday is null or between 1 and 7 |
summary_configs_project_team_check | kind is not project, or linear_team_id is set |
summary_configs_review_flag_days_check | review_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.
summary_recipients
Section titled “summary_recipients”Who gets a summary and may run /summary now for it. Each row is one person or one role.
| Column | Type | Meaning |
|---|---|---|
config_id | uuid, not null | The summary, with ON DELETE cascade |
discord_user_id | text | A person’s Discord id |
discord_role_id | text | A 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.
| Index | On | Purpose |
|---|---|---|
summary_recipients_user_unique | config_id, discord_user_id, where the user id is not null | A person is added to a summary once |
summary_recipients_role_unique | config_id, discord_role_id, where the role id is not null | A role is added to a summary once |
summaries
Section titled “summaries”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.
| Column | Type | Meaning |
|---|---|---|
config_id | uuid, not null | The summary; no cascade |
member_id | uuid, not null | The person it was written for; no cascade |
period_start | timestamptz(3), not null | Start of the period it covers |
period_end | timestamptz(3), not null | End of the period it covers |
content | text, not null | The full summary text. Empty for failed and skipped rows, and after 30 days |
status | varchar(16), not null | See status values below |
delivered_at | timestamptz(3) | Set only when the status is sent |
failure_reason | text | Why it failed or was skipped |
trigger | varchar(16), not null | See trigger values below |
status values: claimed, sent, failed, skipped. trigger values: scheduled, on_demand.
| Lifecycle | Steps |
|---|---|
| Scheduled | Inserted 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 demand | Inserted 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.
| Index | On | Purpose |
|---|---|---|
summaries_scheduled_unique | config_id, member_id, period_end, where trigger is scheduled | Lets the bot claim a scheduled summary only once. On-demand rows never clash |
summaries_member_sent_idx | config_id, member_id, period_end, where status is sent | Finds a person’s previous sent summary |
summaries_status_created_idx | status, created_at | Counts 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.
points_of_interest
Section titled “points_of_interest”What a member follows with /interests. Rows belong to one member and are hard-deleted when removed or when the member row is deleted.
| Column | Type | Meaning |
|---|---|---|
owner_member_id | uuid, not null | The member, with ON DELETE cascade |
person_discord_user_id | text | The followed person’s Discord id |
topic | varchar(200) | Free-text topic |
channel_ids | text[], not null, default {} | Up to 3 channel ids |
prompt | varchar(500) | Extra note for the digest |
| Check | Rule |
|---|---|
points_of_interest_subject_check | A person, a topic or at least one channel is set |
points_of_interest_channels_max_check | At 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.
ticket_prompts
Section titled “ticket_prompts”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.
| Column | Type | Meaning |
|---|---|---|
discord_message_id | text, not null, unique | The message that was checked. One row per message |
channel_id | text, not null | Channel id of that message |
author_id | text, not null | The author’s Discord id |
status | varchar(16), not null, no default | See status values below |
draft_title | varchar(200) | The AI-written title, or the text the author last typed. Null once cleared |
draft_description | text | The AI-written description, or the text the author last typed. Null once cleared. The app limits it to 4000 characters |
draft_team_id | varchar(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_id | varchar(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_identifier | varchar(32) | The Linear identifier. Set on created and duplicate |
linear_issue_url | text | The Linear link. Set on created and duplicate |
failure_reason | text | Why a prompt failed |
status values: prompted, creating, created, dismissed, failed, duplicate. The database does not check them.
| Status | Meaning |
|---|---|
prompted | Inserted before the DM is sent. The author has not decided, or a failed create was released |
creating | The author submitted the form and the ticket is being filed |
created | The Linear ticket exists |
dismissed | The author pressed No thanks |
failed | The DM could not be sent, or the message was deleted during the check |
duplicate | The bot found a matching Linear ticket and sent no DM |
Lifecycle:
| Step | Change |
|---|---|
| Prompt claimed | Inserted 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-check | prompted to failed. failure_reason holds dm_closed, discord_<code>, message_deleted or an error text of up to 300 characters |
No thanks | prompted to dismissed |
| Form submitted | prompted to creating. A creating row whose updated_at is more than 2 minutes old can be claimed again, for example after a crash |
| Ticket filed | creating to created |
| Create failed, or message deleted before filing | creating back to prompted |
| Match found | Inserted 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:
| Index | On | Purpose |
|---|---|---|
ticket_prompts_discord_message_id_unique | discord_message_id | A message is checked and prompted at most once |
ticket_prompts_author_created_idx | author_id, created_at | Counts 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.
meetings
Section titled “meetings”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.
| Column | Type | Meaning |
|---|---|---|
title | text, not null | The meeting title. Empty until the summary is saved |
held_at | timestamp(3) | The meeting date, only when the transcript stated it. Asking falls back to created_at when it is empty |
linear_team_id | varchar(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_id | varchar(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_id | varchar(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_summary | text, not null, default empty | The decisions, one per line |
discussion_summary | text, not null, default empty | The discussion points, one per line |
uploader_id | text, not null | The 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_id | text, unique | The 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 |
source | varchar(16), not null, default upload | upload or voice. The database does not check them |
voice_channel_id | text | The voice channel of a voice meeting. Empty for an upload |
started_at, ended_at | timestamp(3) | When a voice meeting started and ended. Empty for an upload |
channel_id | text, not null | Where 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_ids | text[], 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_sha256 | varchar(64), not null, unique | SHA-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 |
status | varchar(16), not null | processing, posted or failed. The database does not check them |
search | tsvector, generated and stored | to_tsvector of the title, decisions and discussion, kept up to date by the database |
failure_reason | text | Why 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
processingrun refreshesupdated_atevery minute. - A
failedrow, or aprocessingrow not updated for 10 minutes, is taken over with a compare-and-set when the same file is uploaded again. The takeover setsuploader_id,source_message_idandchannel_idto the new upload, and saving the draft again replaces the meeting’s participants and action items. - A
postedrow 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
failedwithaborted, orprocessingand not updated for 10 minutes, is finished once. When Linear is configured, apostedvoice meeting from the last 7 days whose action item DMs never went out gets them sent. See Restart and recovery.
meeting_transcripts
Section titled “meeting_transcripts”The transcript text, kept apart so it can be deleted without touching the summary.
| Column | Type | Meaning |
|---|---|---|
meeting_id | uuid, not null, unique | The meeting, with ON DELETE cascade. One transcript per meeting |
transcript | text, not null | The 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_models | text[], 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 |
search | tsvector, generated and stored | to_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.
meeting_participants
Section titled “meeting_participants”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.
| Column | Type | Meaning |
|---|---|---|
meeting_id | uuid, not null | The meeting, with ON DELETE cascade |
display_name | text, not null | The name as said in the transcript, or the speaker’s display name for a voice meeting |
member_id | uuid | The member the name matched (for a voice meeting, the member who spoke), or null. ON DELETE set null |
meeting_action_items
Section titled “meeting_action_items”One row per action item, up to 15 per meeting. Saving the draft again after a failed run replaces them.
| Column | Type | Meaning |
|---|---|---|
meeting_id | uuid, not null | The meeting, with ON DELETE cascade |
position | integer, not null | The order in the summary |
title | text, not null | The item title |
description | text, not null, default empty | The item description. Becomes the ticket description |
owner_name | text | The owner as said in the meeting |
assignee_member_id | uuid | The member the owner name matched, or null when none or several matched. ON DELETE set null |
status | varchar(16), not null, default pending | See below |
dm_status | varchar(16), not null, default pending | pending, sent or failed |
kind | varchar(16), not null, default new_work | new_work or update_existing. An update changes a ticket that already exists instead of creating one |
cycle_choice | varchar(8), not null, default none | none, 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_kind | varchar(16) | status or comment for an update, null otherwise |
target_status | varchar(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_title | varchar(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_title | varchar(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_id | varchar(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_url | varchar(32), text | The ticket the bot created, or for an update the ticket it changed. Set on created. An update never sets linear_issue_id |
failure_reason | text | Why a direct message failed: not_allowlisted, dm_closed, discord_<code> or send_failed |
status value | Meaning |
|---|---|
pending | Offered, not selected |
approved | Selected in the drop-down |
rejected | Dismissed with Dismiss or Dismiss all |
creating | Claimed, and being filed or applied. A creating row whose updated_at is more than 2 minutes old can be claimed again |
created | The 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.
meeting_excluded_channels
Section titled “meeting_excluded_channels”Voice and stage channels where members may not start voice notes. Rows are removed by /notes-admin include.
| Column | Type | Meaning |
|---|---|---|
channel_id | text, not null, unique | The Discord id of the blocked channel |
added_by | text, not null | The Discord id of the admin who blocked it |
There are no foreign keys. A deleted channel keeps its row.
meeting_settings
Section titled “meeting_settings”One row per server, written by /notes-admin summary-channel set.
| Column | Type | Meaning |
|---|---|---|
guild_id | text, not null, unique | The server id |
summary_channel_name | varchar(100) | The summary channel name an admin chose. Empty means the MEETING_CHANNEL_NAME default applies |
updated_by | text, not null | The 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.
ask_questions
Section titled “ask_questions”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.
| Column | Type | Meaning |
|---|---|---|
member_id | uuid, not null | The asker, with ON DELETE cascade |
discord_user_id | text, not null | The asker’s Discord id |
channel_id | text | The channel of the question. Null when it was not in a channel |
via | varchar(16), not null | command or tag |
source_message_id | text | The tagging message, for tag rows |
context_message_id | text | The message the tagging message replied to, if any |
question | text, not null | The 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 |
status | varchar(16), not null | pending, answered, no_answer or failed |
failure_reason | varchar(64) | The error class name, scope_unavailable or no_guild. Set only when the status is failed |
trace | jsonb, 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_refs | text[], not null, default {} | The sources the answer cited, as discord:<id> or meeting:<id> |
model | varchar(64) | The model that answered |
latency_ms | integer | Milliseconds the answer took |
input_tokens, output_tokens | integer | Token counts of the answer call |
feedback | varchar(8) | up or down |
answered_at | timestamp(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.
Retention
Section titled “Retention”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.
| What | Rule | When it runs |
|---|---|---|
summaries.content | Emptied 30 days after created_at. Rows still claimed and rows already empty are skipped. The row stays. The 30 days are fixed | At every start, even with summaries off, and about once a day while the summary scheduler runs |
ticket_prompts draft fields | draft_title, draft_description and draft_team_id set to null 30 days after created_at. The 30 days are fixed | At every start only, even with ticket prompts off. Never while the bot keeps running |
meeting_transcripts rows | Deleted after MEETING_TRANSCRIPT_RETENTION_DAYS days from created_at. Whole number from 1 to 3650, default 90 | At every start, then once a day, even with the feature off |
ask_questions rows | Deleted after ASK_LOG_RETENTION_DAYS days from created_at. Whole number from 1 to 3650, default 30 | At 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.
- Where these values are set: Commands.
- What the bot keeps about you in plain words: What the bot stores.