Interesting artifacts and learnings must be written back to this document.
Plan status: pre-beads
Ship TalkTriage: a Discord-native, role-gated talk review pipeline.
Target outcome (MVP): each new submission generates a review card in a dedicated #talk-triage channel; authorized reviewers can vote, discuss in a thread, and finalize (accept/waitlist/decline) via a button-driven flow. Finalization posts a status update to the speaker's channel.
/triage slash command (queue view)#talk-triage channel| Area | Files | Notes |
|---|---|---|
| HTTP server | backend/index.ts | Hono app; current submission flow posts organizer notifications |
| Discord REST wrapper | backend/discord.ts | Currently supports createChannel/createInvite/sendMessage/listGuildChannels/getMessages/startThreadFromMessage |
| Message templates | backend/messages.ts | Plain-text templates only |
| Config | backend/config.ts | Reads Discord env vars; needs extension for TalkTriage env vars |
| Shared types | shared/types.ts | Current submission shape; TalkTriage types may be backend-only initially |
| Autothread patterns | backend/autothread/* | Useful patterns: debug endpoints, "plan/dry_run/live", safety guards |
| File | Responsibility | Depends On |
|---|---|---|
backend/talktriage/index.ts | Public API; re-exports service functions | service.ts, types.ts |
backend/talktriage/service.ts | Business logic: createReviewCard, recordVote, finalize, getQueue | db.ts, discord.ts (via interface) |
backend/talktriage/db.ts | DB queries; no business logic or Discord calls | sqlite |
backend/talktriage/types.ts | TalkTriage-specific types (not shared with frontend) | — |
backend/talktriage/messages.ts | Embed/component builders for review cards and notifications | types.ts |
backend/talktriage/interactions.ts | Hono route: signature verify, routing, role gating | service.ts |
interactions.ts, backend/index.ts) → call Service (service.ts)db.ts) + Discord (backend/discord.ts)messages.ts) builds payloads; called by Service before Discord callsbackend/index.ts (submission flow)
│
▼ optional integration via interface
backend/talktriage/service.ts
│
┌────┴────┐
▼ ▼
db.ts discord.ts (existing)
Optional integration contract — Submission flow calls TalkTriage through a single function:
// In backend/talktriage/index.ts
export async function onSubmissionCreated(submission: TalkSubmission): Promise<void>
If TalkTriage is disabled (missing config), this function no-ops. The submission flow does NOT import db.ts or messages.ts directly—only the public onSubmissionCreated entrypoint.
Built by backend/talktriage/messages.ts, consumed by backend/discord.ts:
interface ReviewCardPayload {
embeds: [{
title: string; // "🎤 Talk Submission #42"
description?: string; // Excerpt of talk_context (truncate to fit embed limits)
color: number; // Status-based: pending=gray, reviewing=blue, accepted=green, etc.
fields: [
{ name: "Speaker", value: string, inline: true },
{ name: "Submission", value: string, inline: true }, // "Self" | "On behalf of …"
{ name: "Status", value: string, inline: true },
{ name: "Votes", value: string, inline: false }, // "✅ 2 | 🤔 1 | ❌ 0"
{ name: "Recommendation", value: string, inline: false }, // Only when threshold met
{ name: "Speaker Channel", value: string, inline: false }, // "<#channel_id>"
];
footer: { text: string }; // "Submitted {relative_time}"
}];
components: [ActionRow, ActionRow?]; // Row 1: Vote buttons + Discuss; Row 2 (fallback): Finalize buttons if no thread
}
Format: talktriage:<action>:<param1>:<param2>...
| Action | Format | Example |
|---|---|---|
| Vote | talktriage:vote:<vote_type>:<submission_id> | talktriage:vote:accept:42 |
| Discuss | talktriage:discuss:<submission_id> | talktriage:discuss:42 |
| Finalize | talktriage:finalize:<status>:<submission_id> | talktriage:finalize:accepted:42 |
Validation: Parse with custom_id.split(':'), validate prefix is talktriage, action is in whitelist, and submission_id parses to a finite integer that exists in DB.
| Scenario | Response Type | Body |
|---|---|---|
| PING | 1 (PONG) | { type: 1 } |
| Immediate ack (fast op) | 4 (CHANNEL_MESSAGE_WITH_SOURCE) | { type: 4, data: { content, flags } } |
| Deferred (slow op) | 5 or 6 | { type: 5 } or { type: 6 } |
| Ephemeral error | 4 | { type: 4, data: { content, flags: 64 } } |
| Update original | Follow-up PATCH | PATCH /webhooks/{app_id}/{token}/messages/@original |
// backend/talktriage/service.ts
export async function createReviewCard(submission: TalkSubmission): Promise<{ messageId: string }>
export async function recordVote(submissionId: number, reviewerId: string, vote: Vote): Promise<Tally>
export async function startDiscussion(submissionId: number): Promise<{ threadId: string }>
export async function finalize(submissionId: number, status: FinalStatus, byDiscordId: string): Promise<FinalizeResult>
export async function getQueue(filter?: StatusFilter): Promise<QueueItem[]>
type Vote = 'accept' | 'maybe' | 'pass'
type FinalStatus = 'accepted' | 'waitlisted' | 'declined'
type FinalizeResult = { success: true } | { success: false; reason: 'already_finalized'; currentStatus: FinalStatus }
Owner: backend/config.ts
Deliverables
backend/config.ts extended to export getTalkTriageConfig() returning typed config or null if disabledConfig additions (proposed)
DISCORD_TRIAGE_CHANNEL_ID (required)DISCORD_REVIEWER_ROLE_IDS (required; comma-separated)DISCORD_PUBLIC_KEY (required; interactions signature verification)TALKTRIAGE_MIN_ACCEPT_VOTES (optional; default 3)TALKTRIAGE_ENABLE_FINALIZE_GATING (optional; if true, only enable finalize when threshold met)Acceptance criteria
Verification
Owner: backend/talktriage/db.ts (DB layer; no business logic)
Deliverables
backend/talktriage/db.ts, called from backend/index.ts)Tables (MVP)
talktriage_review_cards_1 (submission_id PK, triage_channel_id, review_message_id, review_thread_id?, created_at)talktriage_votes_1 (submission_id, reviewer_discord_id, vote, updated_at; UNIQUE(submission_id, reviewer_discord_id))talktriage_status_1 (submission_id PK, status, updated_at, updated_by_discord_id)talktriage_status_history_1 (id AUTOINCREMENT, submission_id, old_status, new_status, changed_by_discord_id, changed_at) — append-only transitionsIndexes (required for queue queries)
CREATE INDEX idx_votes_submission ON talktriage_votes_1(submission_id) — tally aggregationCREATE INDEX idx_status_status ON talktriage_status_1(status) — /triage filtering by statusCREATE INDEX idx_cards_created ON talktriage_review_cards_1(created_at) — queue orderingAcceptance criteria
.schema or EXPLAIN QUERY PLAN)Verification
/api/submissions once in test env → see rows in talktriage_* tablesOwner: backend/discord.ts (shared Discord layer)
Deliverables
Extend backend/discord.ts to support the primitives TalkTriage needs.
Additions (proposed)
sendMessageWithComponents(channelId, payload: ReviewCardPayload) — see Contracts: Review Card Message StructureeditMessage(channelId, messageId, payload) — to update tally/statusAcceptance criteria
Verification
Owner: backend/talktriage/interactions.ts (mounts to backend/index.ts Hono app)
Deliverables
POST /api/discord/interactionsDISCORD_PUBLIC_KEYPING interactionSecurity: Signature Verification
discord-interactions npm package via esm.sh (https://esm.sh/discord-interactions) for verifyKey()const signature = c.req.header("X-Signature-Ed25519");
const timestamp = c.req.header("X-Signature-Timestamp");
const body = await c.req.text();
if (!verifyKey(body, signature, timestamp, DISCORD_PUBLIC_KEY)) {
return c.text("Invalid signature", 401);
}
Architecture: 3-second response constraint
{ type: 5 } or { type: 6 } (see below)PATCH /webhooks/{app_id}/{interaction_token}/messages/@original to updateDEFERRED_CHANNEL_MESSAGE_WITH_SOURCE): Use for slash commands (e.g., /triage) — shows "Bot is thinking..."DEFERRED_UPDATE_MESSAGE): Use for button clicks that will update the originating message (e.g., vote buttons updating card tally)Acceptance criteria
Verification
Owner: backend/talktriage/interactions.ts (route layer → calls Service)
Deliverables
/triage → service.getQueue()member.roles intersects DISCORD_REVIEWER_ROLE_IDS may actcustom_id per Contracts: Button custom_id SchemaSecurity: Role validation strategy
interaction.member.roles[]) — Discord guarantees freshness at interaction timeDISCORD_REVIEWER_ROLE_IDS once at startup; store as Set<string> for O(1) lookupinteraction.member.roles.some(r => reviewerRoleSet.has(r))user.id and user.username for audit trailSecurity: Input validation
custom_id format: use structured IDs like talktriage:vote:accept:<submission_id>custom_id prefix matches expected action; reject unknown prefixes with 400submission_id exists in DB before processing; respond with ephemeral error if not foundaccept, maybe, pass onlyAcceptance criteria
custom_id returns 400, not 500Verification
custom_id via debug endpoint → see 400 + safe logOwner: backend/index.ts calls onSubmissionCreated() → backend/talktriage/service.ts
Deliverables
backend/index.ts: add call to onSubmissionCreated(submission) after successful submission (see Architecture: Module Boundaries)backend/talktriage/service.ts: implement createReviewCard() which:
pending) in DBmessages.ts (see Contracts: Review Card Message Structure)DISCORD_TRIAGE_CHANNEL_ID via discord.tsreview_message_id in talktriage_review_cards_1Architecture: Idempotency strategy
1. BEGIN (implicit in Val Town SQLite)
2. SELECT review_message_id FROM talktriage_review_cards_1 WHERE submission_id = ?
3. IF exists: skip creation, return existing message_id
4. ELSE: post message to Discord, INSERT row, return new message_id
/talktriage rebuild can reconcile by searching triage channel for orphan cards (see Operational hardening gate)Concurrency: Rapid duplicate submissions
Acceptance criteria
Verification
Owner: backend/talktriage/service.ts — recordVote() function (see Contracts: Service Layer Function Signatures)
Deliverables
recordVote() upserts talktriage_votes_1 via db.ts, returns Tallyinteractions.ts calls recordVote(), then edits card to show:
accept >= TALKTRIAGE_MIN_ACCEPT_VOTESpending → reviewing on first voteArchitecture: Concurrency handling
INSERT INTO talktriage_votes_1 ... ON CONFLICT(submission_id, reviewer_discord_id) DO UPDATE SET vote=excluded.vote, updated_at=excluded.updated_atSELECT vote, COUNT(*) FROM talktriage_votes_1 WHERE submission_id=? GROUP BY voteArchitecture: Partial failure handling
/triage rebuild <id> can force re-render of card from DB state (see Operational hardening gate)Architecture: Discord API transient failure handling
retryOnce(fn, { retryOn: [500, 502, 503, 504] })submission_id + discord_error_code, return ephemeral "Temporary error, please retry"Retry-After header if present; otherwise back off 1s. Log rate_limit event for monitoringAcceptance criteria
Verification
Owner: backend/talktriage/service.ts — startDiscussion() + finalize() (see Contracts: Service Layer Function Signatures)
Deliverables
startDiscussion(): creates thread (if missing), posts/pins:
Concurrency: Thread creation race
Two users clicking Discuss simultaneously may both attempt thread creation
Pattern: check DB for review_thread_id first; if NULL, create thread, then upsert thread ID
Use upsert: UPDATE talktriage_review_cards_1 SET review_thread_id=? WHERE submission_id=? AND review_thread_id IS NULL
If affected rows = 0, another request won; re-query DB for the winning thread ID
Discord tolerates duplicate startThreadFromMessage calls (returns existing thread), so worst case is two API calls, not two threads
Finalize buttons:
talktriage_status_1 + append status_historydisabled: true in component payload)Security: State transition validation
talktriage_status_1 for current statuspending|reviewing → accepted|waitlisted|declinedUPDATE ... WHERE submission_id=? AND status IN ('pending','reviewing') — check affected rows = 1Architecture: Speaker channel lookup
talk_submissions_3.channel_id (created on submission)SELECT channel_id FROM talk_submissions_3 WHERE id = ?Finalize gating (optional)
TALKTRIAGE_ENABLE_FINALIZE_GATING=true:
disabled: true until accept_votes >= TALKTRIAGE_MIN_ACCEPT_VOTESservice.recordVote() also updates Action Panel message (if thread exists) to enable buttonsFallback: Finalize without thread
components ActionRow)Acceptance criteria
Verification
Owner: backend/talktriage/interactions.ts → service.getQueue()
Deliverables
getQueue() returns QueueItem[] (see Contracts: Service Layer Function Signatures)interactions.ts formats as ephemeral Discord message grouped by statusPOST /applications/{app_id}/commands with:
{ "name": "triage", "description": "View talk submission queue", "options": [{ "name": "status", "description": "Filter by status", "type": 3, "required": false, "choices": [ { "name": "pending", "value": "pending" }, { "name": "reviewing", "value": "reviewing" }, { "name": "accepted", "value": "accepted" }, { "name": "waitlisted", "value": "waitlisted" }, { "name": "declined", "value": "declined" } ] }] }
Performance: Pagination and size limits
/triage call (covers typical backlog)/triage status:pending to filter."status column + ORDER BY created_at ASC LIMIT 26 (fetch 26 to detect overflow)Acceptance criteria
/triage (role check in interactions.ts)Verification
/triage with 5+ submissions in mixed statesOwner: backend/talktriage/service.ts (rebuild logic), README.md (docs)
Deliverables
/talktriage rebuild <id> (or a debug endpoint) to re-post and re-linkRebuild command scope (/talktriage rebuild <submission_id> or debug endpoint POST /api/talktriage/rebuild/:id)
review_message_id exists: attempt to edit existing card (may 404 if deleted)review_message_id in DBreview_thread_id exists but thread deleted: clear thread ID in DB (thread re-created on next Discuss click)rebuild event typeStructured error codes (for logging and debugging)
| Code | Meaning | User-facing message |
|---|---|---|
TRIAGE_001 | Signature verification failed | (no response—401) |
TRIAGE_002 | Unauthorized role | "You don't have permission to do this." |
TRIAGE_003 | Submission not found | "Submission not found." |
TRIAGE_004 | Already finalized | "Already finalized as {status}." |
TRIAGE_005 | Discord API error (after retry) | "Temporary error, please retry." |
TRIAGE_006 | Invalid custom_id format | (400—malformed request) |
Log format: [TRIAGE_XXX] {message} | submission_id={id} user_id={uid}
Security: Rate limiting strategy
/triage command becomes expensive, add per-user cooldown (e.g., 1 request per 10s stored in memory or blob)Acceptance criteria
Verification
discord-interactions via esm.sh; see Gate: Discord Interactions endpointmember.roles vs re-fetching member state → Trust interaction payload roles; see Gate: Command + Component routing