docs/proposals/2026-10-21-discord-forum-tracker-view.md

Discord forum as a view of the built-in tracker

Status: proposal for VUH-2531. James approved the direction on 2026-10-20, and the lead settled the open decisions on 2026-10-21 (below). Slice 1 is built offline in discord-forum-mirror.ts. Live Discord writes wait for the lead's choice of test server, channel and bot.

Date: 2026-10-21

The built-in tracker stays the only store (ADR 0226, VUH-2532 amendment). One Discord forum channel per built-in project is a live two-way view of it:

  • a forum post is an item, and its first message is the description;
  • forum tags carry the status and up to four labels;
  • replies are comments, and evidence links land in the thread.

Discord's limits (read 2026-10-21)

These come from the current docs, not from memory. Sources: rate limits, channels, threads, gateway and audit log.

LimitValue
Global REST rate50 requests/s per bot. Interaction endpoints are exempt.
Per-route ratePer bucket (X-RateLimit-Bucket), keyed by the top-level resource: channel, guild or webhook. The docs say "rate limits should not be hard coded".
429 handlingWait retry_after. X-RateLimit-Scope is user, global or shared.
Invalid requests10,000 per 10 minutes for 401, 403 and 429 responses, or Cloudflare bans the IP. A shared-scope 429 does not count.
Gateway sends120 commands per connection per 60 s.
Tags per forum20 (available_tags), names 0–20 characters. A moderated tag can be added or removed only by MANAGE_THREADS holders.
Tags per post5 (applied_tags).
Post title1–100 characters.
Messagecontent up to 2000 characters; up to 10 embeds totalling 6000 characters; 25 MiB per request.
Forum topic (guidelines)0–4096 characters.
Auto-archive60, 1440, 4320 or 10080 minutes. Under the guild's active-thread cap, Discord may archive sooner. Pinned posts never auto-archive.
Archived threadsNo edits, reactions or commands. A message from anyone with SEND_MESSAGES unarchives the thread. Setting archived:false needs only SEND_MESSAGES unless the thread is locked; a locked thread needs MANAGE_THREADS.
Archived threads on reconnectNot synced over the gateway. List them over REST: GET /channels/{id}/threads/archived/public?before=&limit=.
Starter messageHas the same id as its post.
Message contentPrivileged MESSAGE_CONTENT intent. Without it, content is empty except in the app's own messages, DMs and mentions. Verified apps (100+ guilds) need approval.
Thread eventsGUILDS delivers THREAD_CREATE, THREAD_UPDATE, THREAD_DELETE and THREAD_LIST_SYNC. The docs do not name the event for a tag change; THREAD_UPDATE is expected and the live trial confirms it.
Audit logGET /guilds/{id}/audit-logs, action_type 111 for a thread update, limit 1–100, kept 45 days. The docs do not say whether applied_tags appears in its change list; the live trial checks.

Mapping

Built-in trackerDiscord forum
Project with a Discord viewOne forum channel. The binding records the project, guild and forum id.
ItemA post titled CLK-12 Title, cut to 100 characters.
DescriptionThe starter message, chunked to 2000 characters, with a link to the full item when cut.
Status Backlog / Todo / In ProgressTag Later / Next / Now.
Status In Review or DeliveredTag Review.
Status DoneTag Done, then the post is archived.
Status Canceled or DuplicateTag Closed, then the post is archived.
LabelsUp to 4 label tags per post (5 minus status). The forum holds 6 status tags plus 14 label tags; labels beyond that are listed in the starter message.
CommentA reply. Long comments use up to three messages, then a link.
Evidence linkA reply carrying the clankie://evidence link and caption. No files are re-uploaded.
Blocks, parents, priority, cycles, runs, asksKept only in the tracker. Priority shows in the starter message.

The six status tags are moderated, so Discord itself stops anyone without MANAGE_THREADS from setting a status. Label tags are not moderated.

A label-only edit in the tracker emits no item event, so its tags reach Discord at the item's next status change or comment (accepted for now, 2026-10-21).

Flows

Dedupe and exactly-once

  • Outbound. Each tracker event becomes one journaled effect, following discord-tracking.ts: pending, then dispatching, then done. A crash while dispatching leaves the effect uncertain, visible and never replayed. The cursor and storeId follow tracker-owner-loop.ts, and a store_changed rebootstrap reconciles the maps instead of reposting. Events stamped via=discord_forum are not mirrored back.
  • Inbound. Every write carries idempotencyKey: discord:{snowflake}. The built-in tracker keeps receipts per actor and key, so a redelivered gateway event or a restart replay cannot create a second item or comment. Messages the bot wrote, or that are already mapped, are ignored.
  • Maps. The journal maps item to thread and comment to message. Both are checked before any create, in both directions.
  • Coalescing. Changes to one item that arrive close together become one PATCH per post. The mirror writes to each forum one request at a time and takes the latest canonical tags, not a delta.

Archived threads

  • Before editing an archived post, the mirror sends PATCH {archived:false}, then the change. Posting a reply unarchives the post by itself. Done and Closed items are re-archived after their tag lands.
  • A locked post, or a 403 or 404, stops that projection and records the reason. The mirror never retries in a loop, which protects the invalid-request budget.
  • At startup and every ten minutes, the mirror catches up on what the gateway did not deliver (see slice 2). A tag drift with no attributable owner is not imported; the tracker's tags are restored.

Trust

Owners follow the guild policy (me, everyone or a role) through the same decision as planDiscordTurnSession (ADR 0251).

  • Owner post or reply: written as the owner, human actor, via=discord_forum.
  • Member post or reply: untrusted input, never an instruction.
    • The tracker records the member as an external actor, which is never the owner and never Clankie's own echo. The text starts with a provenance line that marks it as untrusted.
    • Member posts start in Backlog. An owner setting can switch the forum to owner-tag-only, where a member's post waits until an owner sets its status.
    • A per-forum daily limit bounds member posts and replies. Posts over the limit wait for an owner; replies over the limit stay in Discord only.
    • Nothing a member writes can set a status, wake Clankie with tools, start a run or reach a worker's brief unfenced. The owner loop never wakes on external activity.
  • Status-tag change: these tags are moderated, so only MANAGE_THREADS holders or the bot can set them.
    • The forum's overwrites grant MANAGE_THREADS to the owner (or owner role) and the bot only.
    • Members with Discord's Administrator permission bypass overwrites. A change is therefore accepted only when the audit log attributes it to an owner; otherwise the mirror restores the canonical tags.
    • If the live trial shows the audit log does not record applied_tags, every unattributed status change is reverted and the owner changes status from a Clankie surface instead.
  • Label-tag change by a post's author on their own post is applied only when that author is an owner; otherwise it is reverted.
  • The tracker's own rules still apply. For example, Done needs an evidence bundle. If the tracker refuses an owner's tag change, the mirror restores the tags and tells the owner why. If it refuses an owner's new post at a status, the post is filed in Backlog.

Audience

Binding a project to a forum that members can read publishes that project's items to them; it is the owner's explicit choice for that one project. The body's owner-only-audience check refuses thread writes to shared rooms today, so slice 2 adds an exception for exactly the owner-bound forum.

Slices

  1. Offline mirror core (this landing, no live Discord):

    • pure mapping: the tag catalog, a post's tags, title and body chunking;
    • an outbound mirror over a real local built-in tracker, through the existing serverAction path, against a loopback mock of Discord REST;
    • the inbound handler, given normalized forum events and an owner verdict.

    Tests cover: create to post, status to tags, Done to archive, reopen to unarchive, comments in both directions, no duplicates on replay or self-echo, an archived post before an edit, a crash mid-dispatch becoming uncertain, a member post as untrusted Backlog, and a member status change reverted.

  2. Body wiring (built):

    • the bot body reports THREAD_CREATE (new posts), THREAD_UPDATE (tag changes) and MESSAGE_CREATE (replies) from bound forums to POST /v1/captain/discord-forum-events. Only the discord_text captain may call it, and the service decides who is an owner;
    • a tag change is credited only to an audit-log entry for that thread, from the last minute, whose recorded applied_tags equal the post's current tags, so a later unrecorded change never borrows an earlier owner's entry. The view's own edits are dropped as its echo; anything else is not_found and display-only;
    • discord.trackerForums binds a forum to a project. The body's REST fences allow exactly the view's writes there (reads, the forum's tags, new posts, a post's tags and archive state, replies) without the admin role or an owner-only audience;
    • a refused write raises one owner notice per post. The notice clears when that post next syncs, and the write is retried on the item's next change. A transport failure may have reached Discord, so it stays uncertain and is never repeated;
    • catch-up (built after slice 3): at start and every ten minutes, the view lists the forum's active posts and its archived posts since the last pass, then imports new posts, replies after the last message it saw, and tag changes. Who changed tags comes from the audit log, under the same equal-tags rule; an owner's Discord edit that the tracker overtook (the item changed after the edit) is restored, so the newer side wins. The first pass only marks where the view begins, so binding never imports the forum's earlier posts. Every import keeps its Discord-keyed idempotency, so repeated passes change nothing. One post never stalls a pass: a post whose starter Discord refuses to read (deleted) is skipped with one notice, and a post that fails partway is read directly on the next pass, even once archived. The body allows these reads and their paging (after, before, limit, and action_type 111 for the audit log) only for a guild with a bound forum; the active-threads and audit-log reads are guild-wide and stay on the host;
    • not yet: the user-session body and the hosted official bot.
  3. Owner surface:

    • built: clankie discord forum bind|unbind with an explicit --confirm-audience, over the same revision-fenced settings API;
    • built: the host refuses a new binding unless the project exists in the built-in tracker and the body's directory shows a forum channel in that server; unchanged bindings are not proved again;
    • built: the work-tracker read shows a built-in project's bound forum (discordForum, optional so older apps drop it). Discord stays a view in the tracker choices, never a selectable store: adding it to the selection union would break older apps' reads;
    • built: the TUI's Advanced JSON field, and in clankie-app a generic json Advanced field kind plus the bound forum in the work-tracker panel;
    • later, with the hosted follow-up: the hosted dashboard.
  4. Live trial on the lead's chosen test server, channel and bot:

    • each of post, tag change and reply round-trips;
    • an archived post;
    • a duplicate redelivery;
    • a member post and a member tag change;
    • whether the audit log records applied_tags.

Decisions (lead, 2026-10-21)

  1. Member posts create untrusted Backlog items automatically. An owner setting switches a forum to owner-tag-only, and a per-forum daily limit stops a busy server from flooding the tracker.
  2. Status changes made in Discord count only when the audit log attributes them to an owner. Without VIEW_AUDIT_LOG, status tags are display-only, and the owner gets a notice instead of a silent revert.
  3. Self-hosted first. The hosted official bot follows later and stays mention-only until MESSAGE_CONTENT is approved; that is a separate follow-up.