@open-everywhere/provider-discord 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/discord-grant-click.ts +237 -0
  5. package/agent/approvals/discord-admin.ts +227 -0
  6. package/agent/approvals/discord-card.ts +248 -0
  7. package/agent/approvals/discord-flow.ts +469 -0
  8. package/agent/approvals/discord-scope-label.ts +27 -0
  9. package/agent/channels/discord-approval-card.ts +373 -0
  10. package/agent/channels/discord-backscroll.ts +593 -0
  11. package/agent/channels/discord-bot-name.ts +113 -0
  12. package/agent/channels/discord-channel-info.ts +83 -0
  13. package/agent/channels/discord-directory.ts +179 -0
  14. package/agent/channels/discord-dispatch-target.ts +103 -0
  15. package/agent/channels/discord-events.ts +175 -0
  16. package/agent/channels/discord-front.ts +355 -0
  17. package/agent/channels/discord-gateway.ts +3256 -0
  18. package/agent/channels/discord-identity.ts +269 -0
  19. package/agent/channels/discord-interaction-defer.ts +123 -0
  20. package/agent/channels/discord-message-ops.ts +127 -0
  21. package/agent/channels/discord-origin-codec.ts +105 -0
  22. package/agent/channels/discord-post-arm.ts +230 -0
  23. package/agent/channels/discord-post.ts +463 -0
  24. package/agent/channels/discord-read.ts +140 -0
  25. package/agent/channels/discord-rest.ts +85 -0
  26. package/agent/channels/discord.ts +244 -0
  27. package/agent/commands/discord-command-sync.ts +285 -0
  28. package/agent/commands/discord-commands.ts +291 -0
  29. package/agent/contributions.ts +31 -0
  30. package/agent/docs-generated.ts +15 -0
  31. package/agent/identity/discord-link-consent.ts +124 -0
  32. package/agent/ops.ts +14 -0
  33. package/agent/provider-events.ts +86 -0
  34. package/agent/register.ts +147 -0
  35. package/agent/surface-parity.ts +95 -0
  36. package/dist/agent/access/discord-grant-click.d.ts +25 -0
  37. package/dist/agent/access/discord-grant-click.d.ts.map +1 -0
  38. package/dist/agent/access/discord-grant-click.js +210 -0
  39. package/dist/agent/access/discord-grant-click.js.map +1 -0
  40. package/dist/agent/approvals/discord-admin.d.ts +63 -0
  41. package/dist/agent/approvals/discord-admin.d.ts.map +1 -0
  42. package/dist/agent/approvals/discord-admin.js +219 -0
  43. package/dist/agent/approvals/discord-admin.js.map +1 -0
  44. package/dist/agent/approvals/discord-card.d.ts +102 -0
  45. package/dist/agent/approvals/discord-card.d.ts.map +1 -0
  46. package/dist/agent/approvals/discord-card.js +217 -0
  47. package/dist/agent/approvals/discord-card.js.map +1 -0
  48. package/dist/agent/approvals/discord-flow.d.ts +109 -0
  49. package/dist/agent/approvals/discord-flow.d.ts.map +1 -0
  50. package/dist/agent/approvals/discord-flow.js +375 -0
  51. package/dist/agent/approvals/discord-flow.js.map +1 -0
  52. package/dist/agent/approvals/discord-scope-label.d.ts +3 -0
  53. package/dist/agent/approvals/discord-scope-label.d.ts.map +1 -0
  54. package/dist/agent/approvals/discord-scope-label.js +27 -0
  55. package/dist/agent/approvals/discord-scope-label.js.map +1 -0
  56. package/dist/agent/channels/discord-approval-card.d.ts +81 -0
  57. package/dist/agent/channels/discord-approval-card.d.ts.map +1 -0
  58. package/dist/agent/channels/discord-approval-card.js +303 -0
  59. package/dist/agent/channels/discord-approval-card.js.map +1 -0
  60. package/dist/agent/channels/discord-backscroll.d.ts +209 -0
  61. package/dist/agent/channels/discord-backscroll.d.ts.map +1 -0
  62. package/dist/agent/channels/discord-backscroll.js +505 -0
  63. package/dist/agent/channels/discord-backscroll.js.map +1 -0
  64. package/dist/agent/channels/discord-bot-name.d.ts +19 -0
  65. package/dist/agent/channels/discord-bot-name.d.ts.map +1 -0
  66. package/dist/agent/channels/discord-bot-name.js +98 -0
  67. package/dist/agent/channels/discord-bot-name.js.map +1 -0
  68. package/dist/agent/channels/discord-channel-info.d.ts +22 -0
  69. package/dist/agent/channels/discord-channel-info.d.ts.map +1 -0
  70. package/dist/agent/channels/discord-channel-info.js +65 -0
  71. package/dist/agent/channels/discord-channel-info.js.map +1 -0
  72. package/dist/agent/channels/discord-directory.d.ts +11 -0
  73. package/dist/agent/channels/discord-directory.d.ts.map +1 -0
  74. package/dist/agent/channels/discord-directory.js +170 -0
  75. package/dist/agent/channels/discord-directory.js.map +1 -0
  76. package/dist/agent/channels/discord-dispatch-target.d.ts +15 -0
  77. package/dist/agent/channels/discord-dispatch-target.d.ts.map +1 -0
  78. package/dist/agent/channels/discord-dispatch-target.js +99 -0
  79. package/dist/agent/channels/discord-dispatch-target.js.map +1 -0
  80. package/dist/agent/channels/discord-events.d.ts +58 -0
  81. package/dist/agent/channels/discord-events.d.ts.map +1 -0
  82. package/dist/agent/channels/discord-events.js +150 -0
  83. package/dist/agent/channels/discord-events.js.map +1 -0
  84. package/dist/agent/channels/discord-front.d.ts +115 -0
  85. package/dist/agent/channels/discord-front.d.ts.map +1 -0
  86. package/dist/agent/channels/discord-front.js +279 -0
  87. package/dist/agent/channels/discord-front.js.map +1 -0
  88. package/dist/agent/channels/discord-gateway.d.ts +783 -0
  89. package/dist/agent/channels/discord-gateway.d.ts.map +1 -0
  90. package/dist/agent/channels/discord-gateway.js +2591 -0
  91. package/dist/agent/channels/discord-gateway.js.map +1 -0
  92. package/dist/agent/channels/discord-identity.d.ts +110 -0
  93. package/dist/agent/channels/discord-identity.d.ts.map +1 -0
  94. package/dist/agent/channels/discord-identity.js +201 -0
  95. package/dist/agent/channels/discord-identity.js.map +1 -0
  96. package/dist/agent/channels/discord-interaction-defer.d.ts +33 -0
  97. package/dist/agent/channels/discord-interaction-defer.d.ts.map +1 -0
  98. package/dist/agent/channels/discord-interaction-defer.js +102 -0
  99. package/dist/agent/channels/discord-interaction-defer.js.map +1 -0
  100. package/dist/agent/channels/discord-message-ops.d.ts +3 -0
  101. package/dist/agent/channels/discord-message-ops.d.ts.map +1 -0
  102. package/dist/agent/channels/discord-message-ops.js +123 -0
  103. package/dist/agent/channels/discord-message-ops.js.map +1 -0
  104. package/dist/agent/channels/discord-origin-codec.d.ts +7 -0
  105. package/dist/agent/channels/discord-origin-codec.d.ts.map +1 -0
  106. package/dist/agent/channels/discord-origin-codec.js +98 -0
  107. package/dist/agent/channels/discord-origin-codec.js.map +1 -0
  108. package/dist/agent/channels/discord-post-arm.d.ts +3 -0
  109. package/dist/agent/channels/discord-post-arm.d.ts.map +1 -0
  110. package/dist/agent/channels/discord-post-arm.js +195 -0
  111. package/dist/agent/channels/discord-post-arm.js.map +1 -0
  112. package/dist/agent/channels/discord-post.d.ts +120 -0
  113. package/dist/agent/channels/discord-post.d.ts.map +1 -0
  114. package/dist/agent/channels/discord-post.js +426 -0
  115. package/dist/agent/channels/discord-post.js.map +1 -0
  116. package/dist/agent/channels/discord-read.d.ts +40 -0
  117. package/dist/agent/channels/discord-read.d.ts.map +1 -0
  118. package/dist/agent/channels/discord-read.js +128 -0
  119. package/dist/agent/channels/discord-read.js.map +1 -0
  120. package/dist/agent/channels/discord-rest.d.ts +33 -0
  121. package/dist/agent/channels/discord-rest.d.ts.map +1 -0
  122. package/dist/agent/channels/discord-rest.js +75 -0
  123. package/dist/agent/channels/discord-rest.js.map +1 -0
  124. package/dist/agent/channels/discord.d.ts +3 -0
  125. package/dist/agent/channels/discord.d.ts.map +1 -0
  126. package/dist/agent/channels/discord.js +230 -0
  127. package/dist/agent/channels/discord.js.map +1 -0
  128. package/dist/agent/commands/discord-command-sync.d.ts +66 -0
  129. package/dist/agent/commands/discord-command-sync.d.ts.map +1 -0
  130. package/dist/agent/commands/discord-command-sync.js +224 -0
  131. package/dist/agent/commands/discord-command-sync.js.map +1 -0
  132. package/dist/agent/commands/discord-commands.d.ts +45 -0
  133. package/dist/agent/commands/discord-commands.d.ts.map +1 -0
  134. package/dist/agent/commands/discord-commands.js +266 -0
  135. package/dist/agent/commands/discord-commands.js.map +1 -0
  136. package/dist/agent/contributions.d.ts +17 -0
  137. package/dist/agent/contributions.d.ts.map +1 -0
  138. package/dist/agent/contributions.js +31 -0
  139. package/dist/agent/contributions.js.map +1 -0
  140. package/dist/agent/docs-generated.d.ts +3 -0
  141. package/dist/agent/docs-generated.d.ts.map +1 -0
  142. package/dist/agent/docs-generated.js +13 -0
  143. package/dist/agent/docs-generated.js.map +1 -0
  144. package/dist/agent/identity/discord-link-consent.d.ts +13 -0
  145. package/dist/agent/identity/discord-link-consent.d.ts.map +1 -0
  146. package/dist/agent/identity/discord-link-consent.js +111 -0
  147. package/dist/agent/identity/discord-link-consent.js.map +1 -0
  148. package/dist/agent/ops.d.ts +2 -0
  149. package/dist/agent/ops.d.ts.map +1 -0
  150. package/dist/agent/ops.js +13 -0
  151. package/dist/agent/ops.js.map +1 -0
  152. package/dist/agent/provider-events.d.ts +3 -0
  153. package/dist/agent/provider-events.d.ts.map +1 -0
  154. package/dist/agent/provider-events.js +78 -0
  155. package/dist/agent/provider-events.js.map +1 -0
  156. package/dist/agent/register.d.ts +5 -0
  157. package/dist/agent/register.d.ts.map +1 -0
  158. package/dist/agent/register.js +125 -0
  159. package/dist/agent/register.js.map +1 -0
  160. package/dist/agent/surface-parity.d.ts +3 -0
  161. package/dist/agent/surface-parity.d.ts.map +1 -0
  162. package/dist/agent/surface-parity.js +90 -0
  163. package/dist/agent/surface-parity.js.map +1 -0
  164. package/package.json +185 -0
  165. package/tsconfig.build.json +22 -0
  166. package/tsconfig.json +15 -0
@@ -0,0 +1,2591 @@
1
+ // The Discord GATEWAY ingestion surface — Milestone 2 of the Discord Gateway feature.
2
+ //
3
+ // Discord delivers ambient channel/DM messages (MESSAGE_CREATE) ONLY over a persistent
4
+ // Gateway WebSocket, never to an HTTP endpoint — so a serverless deploy can't receive them
5
+ // directly. Milestone 1 (deploy/discord-relay/) is a tiny always-on process that holds that
6
+ // socket and forwards each message here as a signed HTTP POST. THIS file is the receiving
7
+ // end: it verifies the relay's HMAC, reconstructs the real Discord identity, wake-gates like
8
+ // every other surface, dispatches an agent turn on a durable-resume session, and posts the
9
+ // reply back to Discord via REST.
10
+ //
11
+ // MOUNTING CHOICE mirrors channels/webhooks.ts: a plain `defineChannel` with one POST route
12
+ // (`/eve/v1/discord-gateway`), not a front-route over an existing channel (slack-front.ts).
13
+ // The relay POSTs to a BRAND-NEW public surface with no existing owner, so the least-invasive
14
+ // mount is the first-class route primitive — exactly the shape webhooks.ts uses. Unlike the
15
+ // interaction-based channels/discord.ts (Discord's HTTP Interactions → slash commands only),
16
+ // this path carries ambient messages, so it does its OWN identity capture + dispatch and posts
17
+ // replies with the bot token over REST (there is no interaction token to edit/followup).
18
+ //
19
+ // DISPATCH + RESUME. A route handler starts/continues a session via `args.send` (the same
20
+ // primitive eve's own channel wrappers use internally): the continuation token keys the
21
+ // durable session on the Discord channel id, so subsequent messages RESUME it → real
22
+ // multi-turn conversation. The turn runs async (waitUntil); its final reply is posted from
23
+ // the `message.completed` event handler, NOT synchronously in the route. The target channel
24
+ // id is threaded to that handler via the session `state` /
25
+ // `context`, the way eve's discordChannel threads its own channelId.
26
+ //
27
+ // 🔴 A DM DOES NOT TAKE THAT PATH ANY MORE (OPE-356). A `channelType` 1 (DM) or 3 (group DM) is
28
+ // DIVERTED into the DM's own first-class standing window — `divertDiscordDmToWindow` below — so it
29
+ // gets a rotatable, WAKEABLE address instead of a per-channel session eve alone can address, and its
30
+ // reply comes back through the unified channel's arm rather than this file's `message.completed`.
31
+ // The paragraph above still describes every GUILD wake; since OPE-368 removed the
32
+ // `OE_DM_SEAT_ROTATION` escape hatch it no longer describes any DM at all.
33
+ //
34
+ // FAIL-CLOSED. No OE_RELAY_SECRET, or a missing/malformed/mismatched signature → 401, never
35
+ // dispatch. The payload is untrusted JSON: every field is probed defensively, bot authors are
36
+ // dropped, and unresolvable payloads are rejected rather than guessed at.
37
+ //
38
+ // Env this surface reads at runtime:
39
+ // OE_DM_SEAT_ROTATE_* (optional) the DM window's rotation thresholds (core channels/dm-seat.ts),
40
+ // never the supervisor's. A DM window itself is unconditional since OPE-368
41
+ // retired the OE_DM_SEAT_ROTATION master switch.
42
+ // OE_RELAY_SECRET (required) HMAC-SHA256 key shared with the relay; unset → all 401.
43
+ // DISCORD_BOT_TOKEN (required to reply) bot token for the REST create-message/thread calls.
44
+ // discord.autoThread (optional, default ON) a mention in a base channel opens a thread and
45
+ // the conversation re-keys onto it; set "false"/"0" for inline replies.
46
+ // discord.statusUx (optional, default ON) show "working" affordances during a turn — a
47
+ // 👀 "seen/working" reaction on direct addresses (mention/DM; on
48
+ // auto-threaded mentions it is REMOVED when the answer lands — absence
49
+ // = done, ✅ stays reserved for humans) and Discord's NATIVE typing
50
+ // indicator while the turn runs (re-triggered on turn/tool events; it
51
+ // expires by itself, so there is nothing to track or clean up across
52
+ // turns); set "false"/"0" off.
53
+ import { defineChannel, POST } from "eve/channels";
54
+ import { buildDiscordAuth } from "./discord-identity.js";
55
+ import { threadParentChannelId } from "./discord-channel-info.js";
56
+ import { splitForDiscord } from "./discord-post.js";
57
+ import { discordFetch } from "./discord-rest.js";
58
+ import { RateLimitExhaustedError } from "@open-everywhere/core/agent/adapter-api.js";
59
+ import { finalMessageAction, nameDropPattern, wakeNameDropTokens, NO_REPLY_SENTINEL, shouldRotate, renderWake, mkMessageTrigger, supervisorGrainFor, unifiedSupervisorToken, grainUsesUnifiedWindow, mintRotationToken, claimRuntimeRotation, stampMintedRuntime, mintWakeId, discordVendorEventTime, spreadVendorTs, singleWindowMentionPolicy, divertToUnifiedWindow, divertInboundToUnifiedWindow, verifyWebhookSignature, cancelAnchoredTurn, turnAnchorKey, STOP_HAND_UNICODE, STOP_HAND_SLACK_NAME, domainKeyOf, resolvePartition, nameDropWakeEnabled, seatCatchUp, seatCatchUpSinceMs, seatAdmissionFor, admitContextLines, backscrollTrustResolver, engagementTierFor, getConfig, deploymentOrg, isVouchedInternal, resolveWakeTrustFromContext, stampNonMemberChatAuth, contextLineIngestion, resolveExternalPolicy, externalPolicyOverrideFor, withholdsBody, roomIsSilenced, silentRoomNotice, silentNoticeKey, EXTERNAL_SENDER_NOTICE, EXTERNAL_MENTION_ACK, owesSuppressedMentionAck, suppressionRendering, getOperationalStore, appendAudit, carryTailForward, recordWakeTail, tailAuthorGrain, dmWindowRegistryKey, auditSupervisorFailure, healPrunedDeploymentStrand, engagedFollowUpContract, dispatchNativeWakeWithRetry, } from "@open-everywhere/core/agent/adapter-api.js";
60
+ // Re-exported so every existing importer (scripts/discord-gateway-test.ts, the completion
61
+ // post-back below) keeps compiling — the implementation moved to discord-post.ts so the
62
+ // unified reply arm (unified-reply.ts) can split without importing this whole channel module.
63
+ export { splitForDiscord };
64
+ import { bumpThreadSilentStreak, disengageThread, getThreadEngagement, markThreadEngaged, auditNameDropWoke, auditNameDropSilent, WARM_SILENT_LIMIT, ingressSpendGate, consumeCardPostedThisTurn, peekCardPostedThisTurn, consumeRepliedThisTurn, dropClosingProse, isAllowedDriver, parseDriverAllowlist,
65
+ // THE DM SEAT (OPE-356) — the same core mint/touch/flag every first-classed DM window uses; the
66
+ // Slack front route reaches for exactly these three.
67
+ getOrCreateDmSeat, touchDmSeat, buildIngestedReaction, ingestReaction, liveReactionSink, consumeNoReplyThisTurn, consumeStandDownSilenceThisTurn, STAND_DOWN_SILENCE_REASON, NO_REPLY_SENTINEL_REASON, auditNoReplySentinelObserved, getSettings, authPrincipalId, } from "@open-everywhere/core/agent/adapter-api.js";
68
+ import { fetchDiscordBackscrollLines, gradeDiscordContextLines, DISCORD_RAW_FETCH_REACHBACK_HOURS } from "./discord-backscroll.js";
69
+ function log(msg) {
70
+ console.log(`[open-everywhere:discord-gateway] ${msg}`);
71
+ }
72
+ function isObject(v) {
73
+ return typeof v === "object" && v !== null;
74
+ }
75
+ function str(v) {
76
+ return typeof v === "string" && v.length > 0 ? v : undefined;
77
+ }
78
+ /** Coerce a string OR a number (Discord's numeric channel type) to a non-empty string. */
79
+ function strOrNum(v) {
80
+ if (typeof v === "string")
81
+ return v.length > 0 ? v : undefined;
82
+ if (typeof v === "number" && Number.isFinite(v))
83
+ return String(v);
84
+ return undefined;
85
+ }
86
+ /**
87
+ * Defensively parse the relay envelope. Returns null for anything not recognizably a relay
88
+ * MESSAGE_CREATE with the identity facts we MUST have to dispatch + reply — a channel id (for
89
+ * the post-back + partition) and an author id (for identity). `content` may legitimately be
90
+ * empty (Message Content Intent disabled surfaces empty strings), so it isn't required here;
91
+ * the route drops empty-content turns separately. NEVER throws.
92
+ */
93
+ export function parseRelayPayload(raw) {
94
+ if (!isObject(raw))
95
+ return null;
96
+ if (raw["source"] !== "discord-gateway-relay")
97
+ return null; // not our relay's shape
98
+ const m = raw["message"];
99
+ if (!isObject(m))
100
+ return null;
101
+ const channelId = str(m["channelId"]);
102
+ const author = isObject(m["author"]) ? m["author"] : undefined;
103
+ const authorId = author ? str(author["id"]) : undefined;
104
+ if (!channelId || !authorId)
105
+ return null; // no post-back target / no identity → unusable
106
+ const bot = isObject(m["bot"]) ? m["bot"] : undefined;
107
+ const member = isObject(m["member"]) ? m["member"] : undefined;
108
+ return {
109
+ id: str(m["id"]) ?? "",
110
+ content: typeof m["content"] === "string" ? m["content"] : "",
111
+ channelId,
112
+ channelType: strOrNum(m["channelType"]),
113
+ guildId: str(m["guildId"]),
114
+ author: { id: authorId, username: str(author["username"]), bot: author["bot"] === true },
115
+ mentionsBot: m["mentionsBot"] === true,
116
+ referencedMessageId: str(m["referencedMessageId"]),
117
+ // OPE-60 guest/unverified signal — forwarded by a relay that includes the member object;
118
+ // absent from older relays → undefined → today's fail-closed/vouch behaviour is unchanged.
119
+ memberPending: member && typeof member["pending"] === "boolean" ? member["pending"] : undefined,
120
+ // OPE-516 author-trust membership evidence, off the SAME member object. Defensive: only a real
121
+ // array of non-empty strings counts, and an empty result stays `undefined` so "the relay sent no
122
+ // member object" and "this member holds no roles" are not conflated in the shape (they resolve
123
+ // the same way, but the absence is honest).
124
+ memberRoles: Array.isArray(member?.["roles"])
125
+ ? member["roles"].filter((r) => typeof r === "string" && r.length > 0)
126
+ : undefined,
127
+ botName: bot ? str(bot["name"]) : undefined,
128
+ // WHICH bot this delivery came through — the multi-bot attribution field (item 27:
129
+ // N relay clients in one process each forward every message; botId is how dedupe and
130
+ // persona routing tell the copies apart). Carried from the relay since 5c67655.
131
+ botId: bot ? str(bot["id"]) : undefined,
132
+ };
133
+ }
134
+ /**
135
+ * Defensively parse a relay REACTION envelope, or null when it isn't one (wrong source/event,
136
+ * or missing the ids ingestion needs). NEVER throws. Distinguished from a message payload by
137
+ * the `event` discriminator + a `reaction` object (vs `message`).
138
+ */
139
+ export function parseRelayReaction(raw) {
140
+ if (!isObject(raw))
141
+ return null;
142
+ if (raw["source"] !== "discord-gateway-relay")
143
+ return null;
144
+ const ev = raw["event"];
145
+ if (ev !== "MESSAGE_REACTION_ADD" && ev !== "MESSAGE_REACTION_REMOVE")
146
+ return null;
147
+ const r = raw["reaction"];
148
+ if (!isObject(r))
149
+ return null;
150
+ const messageId = str(r["messageId"]);
151
+ const channelId = str(r["channelId"]);
152
+ const reactorId = str(r["reactorId"]);
153
+ const emoji = str(r["emoji"]);
154
+ if (!messageId || !channelId || !reactorId || !emoji)
155
+ return null;
156
+ return {
157
+ verb: ev === "MESSAGE_REACTION_ADD" ? "added" : "removed",
158
+ messageId,
159
+ channelId,
160
+ guildId: str(r["guildId"]),
161
+ channelType: strOrNum(r["channelType"]),
162
+ emoji,
163
+ reactorId,
164
+ messageAuthorId: str(r["messageAuthorId"]),
165
+ botId: str(r["botId"]),
166
+ };
167
+ }
168
+ /**
169
+ * Defensively parse a relay HEARTBEAT envelope, or null when it isn't one (wrong source/event, or
170
+ * missing a field). NEVER throws. Distinguished from a message/reaction envelope by the `event`
171
+ * discriminator + a `heartbeat` object (vs `message`/`reaction`) — mutually exclusive with both,
172
+ * pinned by scripts/discord-gateway-test.ts.
173
+ */
174
+ export function parseRelayHeartbeat(raw) {
175
+ if (!isObject(raw))
176
+ return null;
177
+ if (raw["source"] !== "discord-gateway-relay")
178
+ return null;
179
+ if (raw["event"] !== "HEARTBEAT")
180
+ return null;
181
+ const hb = raw["heartbeat"];
182
+ if (!isObject(hb))
183
+ return null;
184
+ const relayId = str(hb["relayId"]);
185
+ const ts = typeof hb["ts"] === "number" && Number.isFinite(hb["ts"]) ? hb["ts"] : undefined;
186
+ if (!relayId || ts === undefined)
187
+ return null;
188
+ return { relayId, ts };
189
+ }
190
+ /** The audit row a heartbeat writes. Pure — no I/O — so the shape is testable without a live
191
+ * store; the route below is the only caller that actually persists it via `appendAudit`. The
192
+ * action name (`discord.heartbeat`) is what `agent/ops/census.ts`'s `CHANNEL_REPORTED_HEARTBEATS`
193
+ * reads by. `workspace`/`surface` name the RELAY, never a guild — this signal is guild-agnostic
194
+ * by design, which is exactly what lets it separate a dead relay from a quiet one. */
195
+ export function discordHeartbeatAuditRow(heartbeat, org) {
196
+ return {
197
+ org,
198
+ platform: "discord",
199
+ workspace: "relay",
200
+ surface: "heartbeat",
201
+ principalId: heartbeat.relayId,
202
+ action: "discord.heartbeat",
203
+ detail: { relayId: heartbeat.relayId, sentAtIso: new Date(heartbeat.ts).toISOString() },
204
+ };
205
+ }
206
+ /** Is this reaction the ✋ stop affordance? Discord delivers a unicode emoji as the char itself, but
207
+ * a relay (or a custom emoji of the same name) may deliver the NAME — accept both spellings rather
208
+ * than making the stop button depend on which one arrived. */
209
+ function isStopHand(emoji) {
210
+ const bare = emoji.trim();
211
+ return bare === STOP_HAND_UNICODE || bare === STOP_HAND_SLACK_NAME;
212
+ }
213
+ /**
214
+ * The ✋ STOP arm (OPE-418) — cancel the turn anchored on the reacted message.
215
+ *
216
+ * 🔴 THE REACTOR GUARD HERE FAILS CLOSED, and that is a DIFFERENT posture from the feedback path
217
+ * below on purpose. The ingest path skips its bot-reactor drop when `botId` is unresolved, which
218
+ * costs a spurious telemetry row — harmless. Behind `cancel()` the same fail-OPEN would be a
219
+ * SELF-CANCEL: the bot's own ✋ (it places one on every delegating turn) would cancel the very turn
220
+ * that placed it. So a stop requires a POSITIVELY RESOLVED, non-bot reactor: no `botId` ⇒ no stop.
221
+ */
222
+ async function routeStopHandReaction(rx, attachSession) {
223
+ if (!rx.botId) {
224
+ return { recorded: false, onBotReply: false, reason: "stop: bot identity unresolved — refusing to cancel (fail-closed)" };
225
+ }
226
+ if (rx.reactorId === rx.botId) {
227
+ return { recorded: false, onBotReply: false, reason: "stop: the reactor is the bot itself — never a self-cancel" };
228
+ }
229
+ const auth = buildDiscordAuth({ userId: rx.reactorId, guildId: rx.guildId, channelId: rx.channelId, channelType: rx.channelType });
230
+ const p = resolvePartition(ctxFromAuth(auth));
231
+ const result = await cancelAnchoredTurn({
232
+ org: p.org,
233
+ platform: "discord",
234
+ workspace: p.workspace,
235
+ requestedBy: rx.reactorId,
236
+ via: "reaction",
237
+ // The reaction knows EXACTLY which message it landed on, so it resolves by the sharp key and
238
+ // never falls through to the channel's newest-turn guess.
239
+ query: { anchorKey: turnAnchorKey("discord", rx.channelId, rx.messageId) },
240
+ attachSession,
241
+ });
242
+ return { recorded: false, onBotReply: false, reason: `stop: ${result.outcome}` };
243
+ }
244
+ /**
245
+ * Route one parsed Discord reaction into the surface-neutral ingestion seam. The relay already
246
+ * dropped bot reactors, but we double-check against the relayed botId (defence in depth) so the
247
+ * bot's own reaction is never recorded as feedback. onBotReply = the reacted message was authored
248
+ * by the bot; fail-safe when either id is missing (telemetry only, never a false feedback row).
249
+ */
250
+ export async function routeDiscordReaction(rx, deps) {
251
+ // ✋ FIRST, and only on ADD. ✋ is a MECHANISM, not feedback: it means "stop this", so it must
252
+ // never also be recorded as a sentiment signal about the message it sits on. Its REMOVAL is
253
+ // meaningless (the host removes it at turn end) and must never trigger anything.
254
+ if (rx.verb === "added" && isStopHand(rx.emoji) && deps.attachSession) {
255
+ return routeStopHandReaction(rx, deps.attachSession);
256
+ }
257
+ if (rx.botId && rx.reactorId === rx.botId) {
258
+ return { recorded: false, onBotReply: false, reason: "reactor is the bot itself — ignored (not feedback)" };
259
+ }
260
+ const onBotReply = Boolean(rx.botId) && rx.messageAuthorId === rx.botId;
261
+ // Reuse the identity/partition seam (no channelType needed for surfaceKey resolution).
262
+ const auth = buildDiscordAuth({ userId: rx.reactorId, guildId: rx.guildId, channelId: rx.channelId, channelType: rx.channelType });
263
+ const p = resolvePartition(ctxFromAuth(auth));
264
+ const reaction = buildIngestedReaction({
265
+ surface: "discord",
266
+ verb: rx.verb,
267
+ reactorId: rx.reactorId,
268
+ emoji: rx.emoji,
269
+ target: { channelId: rx.channelId, messageId: rx.messageId },
270
+ onBotReply,
271
+ partition: p,
272
+ });
273
+ return ingestReaction(reaction, deps.sink);
274
+ }
275
+ /** Production deps for the Discord reaction route: the live audit/telemetry sink, plus eve's cancel
276
+ * door when the route has one (it is only reachable as `args.attachSession`). */
277
+ function liveDiscordReactionDeps(attachSession) {
278
+ return { sink: liveReactionSink(), ...(attachSession ? { attachSession } : {}) };
279
+ }
280
+ /**
281
+ * Gate one relay delivery. Returns null when it may proceed, else the {@link RelayRejection}.
282
+ * `secret` defaults to the live env read but is injectable so the gate is unit-tested without
283
+ * touching process.env. Fail-closed: unset/empty secret → 401; missing/malformed/mismatched
284
+ * signature → 401 (verifyWebhookSignature returns false for a null or non-`sha256=` header).
285
+ */
286
+ export function relayGate(signatureHeader, rawBody, secret = process.env.OE_RELAY_SECRET) {
287
+ if (!secret) {
288
+ return { status: 401, reason: "OE_RELAY_SECRET is not set — rejecting unverified relay delivery" };
289
+ }
290
+ if (!verifyWebhookSignature(secret, rawBody, signatureHeader)) {
291
+ return { status: 401, reason: "invalid relay signature" };
292
+ }
293
+ return null;
294
+ }
295
+ /**
296
+ * Reconstruct the dispatch auth from a relay message, or null to DROP. Reuses
297
+ * buildDiscordAuth verbatim (channels/discord.ts uses the same for the interaction path) so
298
+ * the identity seam (identity/binding.ts bindDiscord) resolves the real guild/channel/user →
299
+ * workspace/surface/mode/visibility. Drops bot authors: we never act on another bot (incl.
300
+ * ourselves) — mirrors discord.ts's authFromInteraction and the relay's own bot filter.
301
+ */
302
+ export function authForRelayMessage(msg) {
303
+ if (msg.author.bot) {
304
+ // The ONE narrow exception (item 29, e2e driver bot): a guild-pinned, env-allowlisted
305
+ // driver bot is treated as a user so full-path integration scenarios can drive the
306
+ // real agent. Fail-closed default-off (no env → unconditional drop, exactly as
307
+ // before); every acceptance is logged so an armed seam is never invisible.
308
+ if (!isAllowedDriver(msg.guildId, msg.author.id))
309
+ return null;
310
+ log(`e2e driver accepted as user: bot ${msg.author.id} in guild ${msg.guildId} (OE_TEST_DRIVER_IDS)`);
311
+ }
312
+ return buildDiscordAuth({
313
+ userId: msg.author.id,
314
+ guildId: msg.guildId,
315
+ channelId: msg.channelId,
316
+ channelType: msg.channelType,
317
+ // OPE-60: demote a pending (membership-screening-incomplete) member to external. Only set
318
+ // when the relay forwarded the member object; undefined otherwise (unchanged behaviour).
319
+ memberPending: msg.memberPending,
320
+ // OPE-671: the ✋ stop-hand anchor — see DiscordIdentityInput.messageId's doc.
321
+ messageId: msg.id,
322
+ });
323
+ }
324
+ /**
325
+ * Is this bot-authored relay message's author id a KNOWN e2e-driver id (OPE-647) — pinned to
326
+ * SOME guild in `OE_TEST_DRIVER_IDS` — but NOT to this message's own guild? `authForRelayMessage`
327
+ * already refuses the message either way: `isAllowedDriver` is guild-pinned by format
328
+ * (`guildId:botUserId`), so a driver id posting outside its pinned guild never passes. This
329
+ * classifier exists only to tell that refusal APART from routine "some unrelated real bot
330
+ * posted" noise, so a driver credential surfacing in the wrong guild gets a NAMED audit row
331
+ * instead of vanishing into the ordinary bot-drop silence. Pure — no I/O — so every branch is
332
+ * unit-tested against a hand-built allowlist, with no live Discord and no store.
333
+ */
334
+ export function isMisplacedDriver(guildId, botUserId, allowlist = parseDriverAllowlist(process.env.OE_TEST_DRIVER_IDS)) {
335
+ if (!botUserId)
336
+ return false;
337
+ if (guildId !== undefined && allowlist.has(`${guildId}:${botUserId}`))
338
+ return false; // pinned HERE — ordinary admission, not a misplacement
339
+ for (const entry of allowlist) {
340
+ if (entry.endsWith(`:${botUserId}`))
341
+ return true; // pinned to a DIFFERENT guild
342
+ }
343
+ return false;
344
+ }
345
+ /**
346
+ * The AUTHOR-TRUST question for this message (OPE-516) — the operator's own declaration of who is
347
+ * internal in this guild, which is the ONLY author-grain evidence Discord can produce. Discord has
348
+ * no organisation concept, so `discordTrust` can never refute an author; `member.pending` is a
349
+ * SCREENING verdict, not a membership one. The register fills that hole from reviewed config.
350
+ *
351
+ * Returns `{}` for a DM: a DM has no guild, so there is no tenant to arm and no roles to hold. The
352
+ * DM's trust question is about the PARTNER and is answered by the surface register instead — the
353
+ * same split `ChatWakeRoom.dm` makes on the room leg. A spread-able fragment rather than a
354
+ * `| undefined` value so the call sites stay one expression and an absent author is byte-identical
355
+ * to before this existed.
356
+ *
357
+ * The tenant key is built from the guild id and NOTHING ELSE. The platform and tenant halves of a
358
+ * principal key are surface facts, never properties of the id (identity/principal-key.ts) — and a
359
+ * snowflake is a bare digit string that would collide happily across vendors if it were sniffed.
360
+ */
361
+ export function authorFacts(msg) {
362
+ if (!msg.guildId)
363
+ return {};
364
+ return {
365
+ authorTrust: {
366
+ tenantKey: `discord/${msg.guildId}`,
367
+ authorId: msg.author.id,
368
+ // The membership keys this author holds, in the group-rung grammar the register's patterns
369
+ // are written in (identity/surface-key-grammar.ts). Empty when the relay sent no member
370
+ // object — no evidence, which the register reads as "matched nothing".
371
+ memberships: (msg.memberRoles ?? []).map((roleId) => `discord/${msg.guildId}/role:${roleId}`),
372
+ },
373
+ };
374
+ }
375
+ /**
376
+ * The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
377
+ * applied when the domain registry can be read.
378
+ *
379
+ * 🔴 AN UNREADABLE DOMAIN REGISTRY DEGRADES TO THE ORG DEFAULT, IT DOES NOT THROW. Every other
380
+ * `getConfig()` call on this path sits BELOW the DM early-return, so a DM never touched the config
381
+ * registry before this feature existed; making the policy the first thing that does would let an
382
+ * unregistered config take DMs off the air. "Could not read the domain registry" means "no domain
383
+ * override", which resolves to the shipped org default — today's behaviour, which is the only safe
384
+ * answer. A genuinely broken config is still loud everywhere else it is read.
385
+ */
386
+ function externalPolicyForRoom(roomKey) {
387
+ try {
388
+ return resolveExternalPolicy(externalPolicyOverrideFor(roomKey, getConfig().loadDomains()));
389
+ }
390
+ catch {
391
+ return resolveExternalPolicy();
392
+ }
393
+ }
394
+ export function discordExternalVerdict(input) {
395
+ if (withholdsBody(input.author, input.policy))
396
+ return input.addressed ? "withheld-addressed" : "withheld-ambient";
397
+ // A DM contributes NO room leg — on a DM the trust plane asks about the PARTNER, not the room.
398
+ const room = input.isDm ? { dm: true } : { surfaceKey: input.roomKey };
399
+ return roomIsSilenced({ room, ingested: [input.author] }, input.policy) ? "silent-room" : "proceed";
400
+ }
401
+ /** Build the BindableContext the identity/partition seam expects from a dispatch auth. */
402
+ function ctxFromAuth(auth) {
403
+ return { channel: { kind: "discord" }, session: { auth: { current: auth } } };
404
+ }
405
+ /**
406
+ * Is this channel type a THREAD? Discord 10 (announcement thread) / 11 (public thread) /
407
+ * 12 (private thread) — the surfaces whose channel id is NOT the id domains and spend
408
+ * caps are declared against (those name the PARENT channel). Pure; drives the
409
+ * parent-grain stamping in dispatchTurn (item 22).
410
+ */
411
+ export function isThreadChannelType(channelType) {
412
+ return channelType === "10" || channelType === "11" || channelType === "12";
413
+ }
414
+ /**
415
+ * Is this message a direct message to the bot? Discord channel type 1 (DM) / 3 (group DM).
416
+ * A DM is BY DEFINITION addressed to the bot — there is no one else it could be for — so it
417
+ * wakes without a mention and its text is a direct instruction (no judged preamble), exactly
418
+ * like a Slack DM. Pure.
419
+ */
420
+ export function isDmChannel(channelType) {
421
+ return channelType === "1" || channelType === "3";
422
+ }
423
+ /**
424
+ * Should this ambient message wake the agent? A DM always wakes (it's inherently addressed to
425
+ * the bot). An @mention is a direct address (guaranteed reply, like a Slack mention); otherwise
426
+ * the channel must already be ENGAGED — the agent was pulled in earlier and keeps following
427
+ * without re-tagging (engagement.ts, the same registry Slack uses). A non-mention message in a
428
+ * non-engaged guild channel is dropped, so the bot stays quiet in channels it wasn't invited
429
+ * into. Pure → unit-tested without the operational store.
430
+ */
431
+ export function shouldDispatchDiscord(input) {
432
+ return input.isDm || input.mentionsBot || input.isEngaged;
433
+ }
434
+ // Engagement is per-conversation. A Discord thread is its OWN channel id, so the channel id is
435
+ // the right engagement key: the partition surfaceKey already includes it, and using it as the
436
+ // registry's per-thread key keeps threads and their parent channel independently engaged.
437
+ function engagementKey(msg) {
438
+ return msg.channelId;
439
+ }
440
+ /**
441
+ * Is this message a NAME-DROP candidate — Slack's nameDropWake brought to Discord (item 25;
442
+ * live 2026-07-06 Tommy addressed the bot by display name and it only "worked" where the
443
+ * channel happened to be engaged)? Pure pre-filter, cheapest checks first; the caller still
444
+ * gates on the domain's `nameDropWake` flag (config, evaluated at DOMAIN grain — a thread
445
+ * checks its parent channel's domain). Disqualifiers:
446
+ * - mention / DM / engaged — those wake paths own the message already;
447
+ * - a bot author — never self/bot-trigger;
448
+ * - anything but a guild conversation surface (text 0, announcement 5, threads 10/11/12).
449
+ * DELIBERATE Slack DEVIATION: Slack excludes thread replies ("the engagement path owns
450
+ * threads") but on Discord threads ARE the primary conversation surface and an
451
+ * UN-engaged thread (TTL'd out, or human-created) that says the bot's name is a clear
452
+ * address with no other wake path — the exact live gap. Forum containers (15) and
453
+ * unknown types stay excluded, fail-safe;
454
+ * - no relayed bot name (older relay) — fail-closed to mention/engagement-only, so
455
+ * `identity.name`/`wake.nameDrop.include` alone never fires this without a live name too.
456
+ * Unlike Slack there is NO externally-shared-channel probe: Discord has no shared-channel
457
+ * concept — guild membership IS the boundary, so a guild surface is internal by construction.
458
+ * The text is matched against the UNIFIED wake token set (OPE-571): `input.botName` (the live
459
+ * Discord display name) UNION `identity.name`, extended by `wake.nameDrop.include` and narrowed
460
+ * by `wake.nameDrop.exclude` — see {@link wakeNameDropTokens}, the same builder Slack's
461
+ * `buildWakeTriggers` uses, so the two surfaces' wake set stays one rule.
462
+ */
463
+ export function nameDropEligible(input) {
464
+ if (input.mentionsBot || input.isDm || input.isEngaged)
465
+ return false;
466
+ if (input.authorIsBot)
467
+ return false;
468
+ const t = input.channelType;
469
+ if (t !== "0" && t !== "5" && t !== "10" && t !== "11" && t !== "12")
470
+ return false;
471
+ if (!input.botName)
472
+ return false;
473
+ return wakeNameDropTokens(input.botName).some((token) => nameDropPattern(token).test(input.content));
474
+ }
475
+ /**
476
+ * The name-drop road's audit key for one Discord message (OPE-1899) — the partition every other
477
+ * Discord row is keyed by, plus the two ids a reader joins on. Built here rather than at the two
478
+ * call sites so the ADMIT row and the SILENT row can never key differently.
479
+ * 🔴 `surface` IS THE WHOLE `surfaceKey`, NOT THE BARE CHANNEL ID, because that is what every other
480
+ * audit row this file writes carries (`discord.external_policy_suppressed`, the spend gate, the
481
+ * dispatch row). An operator answering "is this road dead in that room" filters by that value, so a
482
+ * row keyed any other way is a row their query cannot see.
483
+ * Discord's messages carry no separate timestamp on these facts, so the message's own id IS its
484
+ * surface locator — the same value the ✋ anchor and the reaction swap address a message by.
485
+ */
486
+ function nameDropAuditKey(partition, msg) {
487
+ return {
488
+ org: partition.org,
489
+ platform: partition.platform,
490
+ workspace: partition.workspace,
491
+ surface: partition.surfaceKey,
492
+ principalId: msg.author.id,
493
+ container: msg.channelId,
494
+ ts: msg.id,
495
+ };
496
+ }
497
+ // --- turn message -----------------------------------------------------------
498
+ //
499
+ // (The old renderTurn / renderNameDropTurn hand-rolled prose renderers were RETIRED in the
500
+ // NormalizedWake migration — a design note commit 3. The native dispatch
501
+ // now builds a {@link discordNativeWake} and renders it through the ONE shared renderWake, so
502
+ // Discord's judged/name-drop wakes carry the canonical Slack-parity preamble + the full wake
503
+ // envelope instead of a drifted prose stub. Direct DM/@mention bodies still pass through verbatim,
504
+ // via renderWake's `direct` addressing — W4.)
505
+ // Bot-auth header shared by every REST call below. `Bot <token>` is Discord's scheme; the token
506
+ // is a SECRET — it rides in the header, NEVER in a log line or error string. Returns null when
507
+ // there is no token, so each caller degrades to today's behaviour (silence) rather than throwing.
508
+ function botAuthHeaders(token) {
509
+ return { authorization: `Bot ${token}`, "content-type": "application/json" };
510
+ }
511
+ // --- the gateway's 429 backoff (OPE-1311) -----------------------------------
512
+ //
513
+ // Every REST write below goes through `discordFetch` (discord-rest.ts), the ONE Discord write funnel,
514
+ // so a rate-limited gateway write honours the delay Discord names — in the JSON `retry_after` body
515
+ // field, which wins over the header. OPE-1236 put that funnel over the reply seam and the message
516
+ // ops; this file was out of that ticket's scope and failed on Discord's FIRST refusal.
517
+ //
518
+ // 🔴 EXHAUSTION IS LOGGED BY NAME, THEN SWALLOWED. Every write here is fail-silent by design: the
519
+ // turn already ran, and no failure arm may throw into the caller. So a `RateLimitExhaustedError`
520
+ // would leave NO trace at all — the silent drop the backoff exists to prevent. Each catch below
521
+ // therefore names it before swallowing it, the posture `slackForm` and the wake-backscroll fetcher
522
+ // take (OPE-1211). Every field on that error is token-free and id-free, so the line is safe to log.
523
+ /**
524
+ * The tighter policy the two EPHEMERAL status affordances use — typing and the 👀 reaction.
525
+ *
526
+ * 🔴 THE SHARED DEFAULTS ARE WRONG FOR THESE TWO, AND THE REASON IS LATENCY, NOT CORRECTNESS. Both
527
+ * are awaited INLINE on the turn's own path: `triggerDiscordTyping` runs inside the streaming loop
528
+ * and the reaction is set before the turn starts. The shared 4-attempt / 30 s-per-wait / 60 s-budget
529
+ * policy would stall a live turn for up to a minute to deliver a typing indicator that Discord
530
+ * expires after 10 seconds — a worse outcome than dropping the affordance, which is exactly what the
531
+ * code did before this backoff existed. So they get one retry and a 2 s ceiling, and give up.
532
+ *
533
+ * This mirrors `slack-web-api.ts`, which passes the same shape for the `auth.test` identity probe on
534
+ * Slack's 3-second event-ack path, and the reason `RetryPolicy` exists as a caller-settable shape.
535
+ * The writes that must LAND — the completion post-back, the open-DM, the thread open — keep the
536
+ * shared defaults.
537
+ *
538
+ * 🔴 IT IS SPREAD LAST, SO A CALLER CANNOT WIDEN IT. The two functions merge it OVER `deps.retry`, not
539
+ * under it. A test may still inject a `sleep` or a clock — this policy names neither — but nothing can
540
+ * hand the typing indicator the 60 s budget, which is the whole point of it having its own policy.
541
+ */
542
+ const AFFORDANCE_RETRY = { maxAttempts: 2, capMs: 2_000, totalBudgetMs: 2_000 };
543
+ /**
544
+ * Low-level create-message: `POST /channels/{id}/messages` with the bot token. Returns the NEW
545
+ * message's id on success (needed by the holding-message flow so completion can edit it), or null
546
+ * on any failure — no token, non-ok status, network error, or a response missing an id. NEVER
547
+ * throws. postDiscordReply + postHoldingMessage are both built on this so the auth/error handling
548
+ * lives in one place (the bot token is a SECRET — never logged).
549
+ */
550
+ export async function discordCreateMessage(channelId, content, deps = {}) {
551
+ const token = process.env.DISCORD_BOT_TOKEN;
552
+ if (!token) {
553
+ log(`DISCORD_BOT_TOKEN is not set — cannot post to channel ${channelId}`);
554
+ return null;
555
+ }
556
+ if (!channelId) {
557
+ log("no channel id — nothing to post to");
558
+ return null;
559
+ }
560
+ try {
561
+ const res = await discordFetch("POST /channels/{id}/messages", `https://discord.com/api/v10/channels/${channelId}/messages`, {
562
+ method: "POST",
563
+ headers: botAuthHeaders(token),
564
+ body: JSON.stringify({ content }),
565
+ }, deps);
566
+ if (!res.ok) {
567
+ log(`Discord create-message failed (${res.status}) for channel ${channelId}`);
568
+ return null;
569
+ }
570
+ const body = (await res.json());
571
+ return isObject(body) ? (str(body["id"]) ?? null) : null;
572
+ }
573
+ catch (err) {
574
+ // The post-back is fail-silent, so an exhausted 429 retry must still leave a trace — see the
575
+ // backoff note above. The error's own message names the attempts and the total wait.
576
+ if (err instanceof RateLimitExhaustedError) {
577
+ log(`Discord create-message rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
578
+ return null;
579
+ }
580
+ log(`Discord create-message error for channel ${channelId}: ${err?.message ?? String(err)}`);
581
+ return null;
582
+ }
583
+ }
584
+ /**
585
+ * Post the agent's reply to a Discord channel via REST. Best-effort per chunk: a failed chunk is
586
+ * logged (with status, never the token) and the rest still attempt. No DISCORD_BOT_TOKEN → each
587
+ * chunk logs and no-ops (the turn already ran; silence is the only degradation). This is the plain
588
+ * post-back used when status UX is off, and the fail-open fallback when the holding-message flow
589
+ * couldn't run.
590
+ */
591
+ /**
592
+ * Open (or re-open) the 1:1 DM channel with one user and return its id — `POST /users/@me/channels`.
593
+ * Idempotent on Discord's side: it returns the EXISTING DM channel when one is already open, so
594
+ * calling it repeatedly creates nothing.
595
+ *
596
+ * It exists for ONE caller: the external-sender notice (OPE-507 behaviour 1), which owes the sender a
597
+ * PRIVATE message. Fail-soft to `null` — see {@link postExternalSenderNotice} for why a failure here
598
+ * must stay silent rather than fall back to the channel.
599
+ */
600
+ export async function openDiscordDm(userId, deps = {}) {
601
+ const token = process.env.DISCORD_BOT_TOKEN;
602
+ if (!token)
603
+ return null;
604
+ try {
605
+ const res = await discordFetch("POST /users/@me/channels", "https://discord.com/api/v10/users/@me/channels", {
606
+ method: "POST",
607
+ headers: { ...botAuthHeaders(token), "content-type": "application/json" },
608
+ body: JSON.stringify({ recipient_id: userId }),
609
+ }, deps);
610
+ if (!res.ok)
611
+ return null;
612
+ const body = (await res.json());
613
+ return typeof body.id === "string" ? body.id : null;
614
+ }
615
+ catch (err) {
616
+ // 🔴 THIS CATCH USED TO BE BARE, AND THAT IS WHY THE NAME MATTERS MOST HERE. Every other failure
617
+ // arm on this path already logs; a swallowed `RateLimitExhaustedError` here would be the ONE
618
+ // Discord write whose rate-limit give-up left no trace anywhere. The notice this DM carries is
619
+ // never said elsewhere (see `postExternalSenderNotice`), so the drop is otherwise invisible.
620
+ // `userId` is a Discord snowflake, not personal data, and the error carries no token.
621
+ if (err instanceof RateLimitExhaustedError) {
622
+ log(`Discord open-DM rate limited for user ${userId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
623
+ }
624
+ return null;
625
+ }
626
+ }
627
+ /**
628
+ * Tell an EXCLUDED sender, PRIVATELY, that the agent will not act on their message (OPE-507
629
+ * behaviour 1). Best-effort and silent on failure.
630
+ *
631
+ * 🔴 THIS NOTICE NEVER GOES TO THE GUILD CHANNEL, AND THAT RULING STANDS. Its text names the
632
+ * organisation, so posting it where the room can read it would announce that this person is
633
+ * classified as an outsider — a disclosure about a member of the room, not a notice to the sender.
634
+ * If the DM cannot be opened (the sender blocks bot DMs, the token is absent, Discord refuses),
635
+ * THIS notice is not said anywhere.
636
+ *
637
+ * 🔴 THE `false` RETURN IS LOAD-BEARING (OPE-885). An EXPLICITLY ADDRESSED wake this notice could
638
+ * not reach falls back to the channel's fixed `EXTERNAL_MENTION_ACK` — a different string precisely
639
+ * because it discloses nothing. So the caller needs the truth about delivery, and every failure arm
640
+ * answers `false`.
641
+ */
642
+ async function postExternalSenderNotice(userId) {
643
+ const dmId = await openDiscordDm(userId);
644
+ if (!dmId)
645
+ return false;
646
+ return (await discordCreateMessage(dmId, EXTERNAL_SENDER_NOTICE)) !== null;
647
+ }
648
+ /**
649
+ * Post the suppressed-wake half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER
650
+ * ROOM, EVER.
651
+ *
652
+ * 🔴 DISCORD POSTS THIS HALF AND NOT THE JOIN HALF, AND THAT IS SETTLED (OPE-525). The relay forwards
653
+ * MESSAGES, not guild/channel join events, so there is no trigger the join notice could hang on. A
654
+ * Discord room is therefore announced ONCE — at the first wake the classification silences — rather
655
+ * than twice. The ruling the pair encodes is "at most twice, never periodic"; one is inside it.
656
+ *
657
+ * 🔴 THE CLAIM IS DURABLE, NOT PER-PROCESS. `claimNotice` writes a permanent row; an in-memory
658
+ * marker would re-announce on every cold start, and a cold start happens per serverless invocation.
659
+ * That would be the periodic announcement the design explicitly rejects, wearing a "once" label.
660
+ *
661
+ * FAIL-CLOSED on the claim, which is the inverse of `postOnce`'s posture and deliberate: if the store
662
+ * cannot answer, the agent stays QUIET. A duplicate here is not a lost message, it is the exact
663
+ * repetition this pair exists to avoid, so the safe direction is silence.
664
+ */
665
+ async function announceSilentRoom(surfaceKey, channelId) {
666
+ const org = deploymentOrg();
667
+ if (!org)
668
+ return false;
669
+ try {
670
+ const won = await getOperationalStore().claimNotice(org, silentNoticeKey(surfaceKey, "suppressed-wake"), new Date().toISOString());
671
+ if (!won)
672
+ return false; // already said, once, forever
673
+ }
674
+ catch {
675
+ return false; // cannot prove it is unsaid ⇒ stay quiet
676
+ }
677
+ await discordCreateMessage(channelId, silentRoomNotice("suppressed-wake"));
678
+ return true;
679
+ }
680
+ async function postDiscordReply(channelId, text) {
681
+ for (const chunk of splitForDiscord(text)) {
682
+ if (!chunk.trim())
683
+ continue; // belt-and-braces: never post an invisible empty message
684
+ await discordCreateMessage(channelId, chunk);
685
+ }
686
+ }
687
+ /**
688
+ * Fire Discord's NATIVE typing indicator: `POST /channels/{id}/typing` → "<bot> is typing…"
689
+ * shown to everyone in the channel for ~10s. This is the working signal while a turn runs —
690
+ * chosen over a posted "holding" message because it's the idiom Discord users already read
691
+ * as "working", it involves NO message (so no "(edited)" tag on the final reply and no
692
+ * delete-and-repost dance), and it EXPIRES BY ITSELF — nothing to track across turns, which
693
+ * is what makes it immune to the resumed-session staleness class (see the state comment).
694
+ * Re-triggered from turn/tool event handlers to cover long turns. Purely an affordance:
695
+ * best-effort, every failure swallowed, missing token no-ops, NEVER throws.
696
+ */
697
+ // Throttle for the HIGH-FREQUENCY typing triggers (the streamed *.appended events, which can
698
+ // fire many times a second): re-POST only when the previous trigger is old enough that the
699
+ // ~10s indicator is nearing expiry. In-process state — if an event handler lands in a fresh
700
+ // invocation the map is cold and we simply POST again (an extra cheap call, never a miss).
701
+ const lastTypingAt = new Map();
702
+ const TYPING_REFRESH_MS = 7_000;
703
+ /** Test hook: drop every recorded throttle timestamp. Production correctness needs this
704
+ * map to live for the whole process (the throttle is meant to span turns), but the
705
+ * golden-transcript scenario harness (discord-scenario-harness.ts) runs many scenarios in ONE
706
+ * process and reuses small deterministic channel/thread ids ("TH1", …) across them — so
707
+ * without a reset, whether a later scenario's typing throttle "holds" depends on the
708
+ * REAL wall-clock gap since an earlier, unrelated scenario last wrote the same key: fast
709
+ * test execution (<7s between scenarios) silently suppresses a typing re-trigger the
710
+ * golden doesn't expect to be suppressed; a slow/loaded run (>7s) doesn't, and the extra
711
+ * POST diverges the golden — a genuine flake pinned as OPE-69 (confirmed by forcing an
712
+ * 8s real delay between scenarios 01/02, which flipped the throttle and diverged
713
+ * 02-thread-followup-resume). Called by ScenarioRunner.scenario() before every scenario
714
+ * so each starts cold, exactly like clearDiscordInfoCacheForTest(). */
715
+ export function clearTypingThrottleForTest() {
716
+ lastTypingAt.clear();
717
+ }
718
+ /** True when a typing re-trigger is due for this channel (and records it as fired). Pure
719
+ * given an injected clock → unit-tested; the default clock is Date.now. */
720
+ export function typingRefreshDue(channelId, nowMs = Date.now(), state = lastTypingAt) {
721
+ const last = state.get(channelId);
722
+ if (last !== undefined && nowMs - last < TYPING_REFRESH_MS)
723
+ return false;
724
+ state.set(channelId, nowMs);
725
+ return true;
726
+ }
727
+ // Judged-wake typing gate (live 2026-07-07): engaged and name-drop wakes run a JUDGED turn
728
+ // that may end in the NO_REPLY sentinel — nothing gets posted. Firing typing at dispatch for
729
+ // those turns produced "typing… for ~10s, then nothing" (Tommy's "test" in an engaged
730
+ // channel), indistinguishable from a crash. Discord typing can only be triggered, never
731
+ // cancelled — so the only lever is to NOT start it until the model has proven it's replying:
732
+ // the streamed message text deviating from the sentinel. Addressed wakes (mention/DM) always
733
+ // answer → they keep dispatch-time typing. In-process, keyed by reply channel id, fail-OPEN:
734
+ // a cold map (event handler in a fresh invocation) just behaves like today — typing during
735
+ // judgment — never a lost reply or a stuck indicator (the indicator expires by itself).
736
+ const closedTypingGates = new Set();
737
+ /** Close the gate for a judged wake: hold all typing until the reply is proven. */
738
+ export function closeTypingGate(channelId, gates = closedTypingGates) {
739
+ gates.add(channelId);
740
+ }
741
+ /** Open (clear) the gate — addressed dispatch, reply proven, or turn over. */
742
+ export function openTypingGate(channelId, gates = closedTypingGates) {
743
+ gates.delete(channelId);
744
+ }
745
+ export function typingGateOpen(channelId, gates = closedTypingGates) {
746
+ return !gates.has(channelId);
747
+ }
748
+ /**
749
+ * Feed the streamed assistant text into the gate; returns whether typing may fire. Mirrors
750
+ * slack-events.ts isNoReply EXACTLY (trimStart + case-sensitive startsWith): while the text
751
+ * is still a prefix of the sentinel the verdict is UNDECIDED (hold); once it starts with the
752
+ * full sentinel it IS a judged silence (hold — the final reply will be swallowed); any
753
+ * deviation proves a real reply, so the gate opens and stays open for the rest of the turn.
754
+ */
755
+ export function typingGateFeed(channelId, messageSoFar, gates = closedTypingGates) {
756
+ if (!gates.has(channelId))
757
+ return true;
758
+ const t = messageSoFar.trimStart();
759
+ if (t.startsWith(NO_REPLY_SENTINEL) || NO_REPLY_SENTINEL.startsWith(t))
760
+ return false;
761
+ gates.delete(channelId);
762
+ return true;
763
+ }
764
+ export async function triggerDiscordTyping(channelId, deps = {}) {
765
+ const token = process.env.DISCORD_BOT_TOKEN;
766
+ if (!token || !channelId)
767
+ return;
768
+ try {
769
+ const res = await discordFetch("POST /channels/{id}/typing", `https://discord.com/api/v10/channels/${channelId}/typing`, { method: "POST", headers: { authorization: `Bot ${token}` } }, { ...deps, retry: { ...deps.retry, ...AFFORDANCE_RETRY } });
770
+ if (!res.ok) {
771
+ log(`Discord typing trigger failed (${res.status}) for channel ${channelId}`);
772
+ }
773
+ }
774
+ catch (err) {
775
+ // The typing indicator is an affordance on the turn's own latency path — it gives up fast (see
776
+ // AFFORDANCE_RETRY) and the give-up is logged, never thrown.
777
+ if (err instanceof RateLimitExhaustedError) {
778
+ log(`Discord typing trigger rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
779
+ return;
780
+ }
781
+ log(`Discord typing trigger error for channel ${channelId}: ${err?.message ?? String(err)}`);
782
+ }
783
+ }
784
+ // The status emoji. encodeURIComponent yields exactly Discord's expected percent-encoding
785
+ // (👀 → %F0%9F%91%80). There is deliberately NO ✅: the convention (established on Slack —
786
+ // see status/status.ts) is 👀 = seen/working, REMOVAL = done, and ✅ stays reserved for
787
+ // humans to mark things themselves. The bot never plants a tick.
788
+ const EYES_EMOJI = "👀";
789
+ /**
790
+ * Add or remove the bot's own reaction on a message: `{PUT|DELETE}
791
+ * /channels/{id}/messages/{messageId}/reactions/{emoji}/@me` with the emoji URL-encoded. Used to
792
+ * show 👀 the moment we decide to respond and swap to ✅ (or just clear 👀) on completion. Purely a
793
+ * status affordance — best-effort, any failure is logged and swallowed, NEVER throws, and a missing
794
+ * token no-ops. (The bot token is a SECRET — never logged.)
795
+ */
796
+ export async function setDiscordReaction(channelId, messageId, emoji, action, deps = {}) {
797
+ const token = process.env.DISCORD_BOT_TOKEN;
798
+ if (!token)
799
+ return; // no token → silently skip the affordance (degrades to no reaction)
800
+ if (!channelId || !messageId)
801
+ return;
802
+ const encoded = encodeURIComponent(emoji);
803
+ try {
804
+ const res = await discordFetch(
805
+ // The route SHAPE, id-free and emoji-free, so the named failure carries no ids — the same
806
+ // operation string discord-post.ts uses for its own reaction write.
807
+ `${action} /channels/{id}/messages/{id}/reactions/{emoji}/@me`, `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/reactions/${encoded}/@me`, { method: action, headers: { authorization: `Bot ${token}` } }, { ...deps, retry: { ...deps.retry, ...AFFORDANCE_RETRY } });
808
+ // A DELETE of a reaction that was never added 404s — harmless, so we don't treat it as notable.
809
+ if (!res.ok && !(action === "DELETE" && res.status === 404)) {
810
+ log(`Discord reaction ${action} failed (${res.status}) for message ${messageId} in channel ${channelId}`);
811
+ }
812
+ }
813
+ catch (err) {
814
+ // The 👀 reaction is an affordance on the turn's own latency path — it gives up fast (see
815
+ // AFFORDANCE_RETRY) and the give-up is logged, never thrown.
816
+ if (err instanceof RateLimitExhaustedError) {
817
+ log(`Discord reaction ${action} rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
818
+ return;
819
+ }
820
+ log(`Discord reaction ${action} error for channel ${channelId}: ${err?.message ?? String(err)}`);
821
+ }
822
+ }
823
+ // --- auto-thread (one task = one thread) ------------------------------------
824
+ // The signature Slack pattern is "mention starts a thread; the work happens in it." Discord
825
+ // threads are first-class channel objects (a thread IS its own channel id), so we map the
826
+ // same model: a mention in a base guild text/announcement channel spins up a thread and the
827
+ // WHOLE conversation re-keys onto it (its own surface silo + durable session + engagement).
828
+ // Everything below is pure/deterministic (unit-tested) except createDiscordThread's REST call.
829
+ /** Strip ONE leading bot-mention token (`<@123>` / `<@!123>`) — shared by thread naming and
830
+ * the stop-verb match, both of which care about the words AFTER the address. Pure. */
831
+ export function stripLeadingMention(content) {
832
+ return content.replace(/^\s*<@!?[^>]+>\s*/, "");
833
+ }
834
+ /**
835
+ * Should a mention here open a new thread? True ONLY for a base guild text (0) or announcement
836
+ * (5) channel when the toggle is on. False for threads (10/11/12 — already a thread surface),
837
+ * DMs / group DMs (1/3 — no threads), forum channels (15 — a forum post is itself a thread;
838
+ * don't nest), and any unknown type (fail-safe: keep today's inline behaviour). Pure.
839
+ */
840
+ export function shouldAutoThread(channelType, enabled) {
841
+ if (!enabled)
842
+ return false;
843
+ return channelType === "0" || channelType === "5";
844
+ }
845
+ /**
846
+ * Read the discord.autoThread toggle. Default ON — auto-thread is the on-brand behaviour;
847
+ * a deployer opts OUT of it. Only the explicit "false"/"0" disable; anything else (unset,
848
+ * "true", "1", …) leaves it on. `env` is injectable so the toggle is unit-tested without
849
+ * touching process.env. Pure.
850
+ */
851
+ export function autoThreadEnabled(env) {
852
+ return env === undefined ? getSettings().discord.autoThread : env !== "false" && env !== "0";
853
+ }
854
+ /**
855
+ * Read the discord.statusUx toggle. Default ON — the "working" affordances (👀/✅ reaction + a
856
+ * holding message edited into the reply) are on-brand, so a deployer opts OUT. Only the explicit
857
+ * "false"/"0" disable; anything else (unset, "true", "1", …) leaves it on. When off, the surface
858
+ * behaves EXACTLY as before this feature: one plain reply post, no reaction, no holding message.
859
+ * `env` is injectable so the toggle is unit-tested without touching process.env. Pure. Mirrors
860
+ * autoThreadEnabled verbatim.
861
+ */
862
+ export function statusUxEnabled(env) {
863
+ return env === undefined ? getSettings().discord.statusUx : env !== "false" && env !== "0";
864
+ }
865
+ /**
866
+ * Derive a Discord thread name from the triggering message. Strips a single leading bot
867
+ * mention token (`<@123>` or `<@!123>`), collapses whitespace, and truncates to Discord's
868
+ * 100-char cap (trimming back to a word boundary when one sits reasonably near the cut).
869
+ * Falls back to `Conversation with <botName>` when nothing usable remains (e.g. a bare
870
+ * mention with no text). Pure + deterministic — no model call (v1; a cheap-model title is a
871
+ * noted later enhancement).
872
+ */
873
+ export function deriveThreadName(content, botName) {
874
+ const fallback = `Conversation with ${botName ?? "the agent"}`;
875
+ // Strip ONE leading mention token, then collapse all runs of whitespace to single spaces.
876
+ const cleaned = stripLeadingMention(content).replace(/\s+/g, " ").trim();
877
+ if (cleaned.length === 0)
878
+ return fallback; // empty / mention-only → fallback
879
+ if (cleaned.length <= 100)
880
+ return cleaned;
881
+ // Over the cap: hard-slice to 100, then prefer a word boundary if one is close to the end
882
+ // (≥80) so we don't cut mid-word; otherwise keep the hard slice.
883
+ const hard = cleaned.slice(0, 100);
884
+ const lastSpace = hard.lastIndexOf(" ");
885
+ return (lastSpace >= 80 ? hard.slice(0, lastSpace) : hard).trim();
886
+ }
887
+ /**
888
+ * Create a Discord thread hanging off the triggering message:
889
+ * `POST /channels/{channelId}/messages/{messageId}/threads` with the bot token. Returns the
890
+ * new thread's channel id on success, or null on ANY failure (no token, non-ok status,
891
+ * network error, or a response missing an id). NEVER throws — threading is fail-OPEN: a null
892
+ * here degrades the turn to an inline reply, it never drops the turn. Mirrors postDiscordReply's
893
+ * auth + error handling exactly (including: the bot token is a SECRET — never logged).
894
+ */
895
+ export async function createDiscordThread(channelId, messageId, name, deps = {}) {
896
+ const token = process.env.DISCORD_BOT_TOKEN;
897
+ if (!token) {
898
+ log(`DISCORD_BOT_TOKEN is not set — cannot create thread on channel ${channelId}`);
899
+ return null;
900
+ }
901
+ if (!channelId || !messageId) {
902
+ log("createDiscordThread: missing channel/message id — cannot create thread");
903
+ return null;
904
+ }
905
+ try {
906
+ const res = await discordFetch("POST /channels/{id}/messages/{id}/threads", `https://discord.com/api/v10/channels/${channelId}/messages/${messageId}/threads`, {
907
+ method: "POST",
908
+ headers: {
909
+ // `Bot <token>` is Discord's bot-auth scheme. The token is a SECRET — never log it.
910
+ authorization: `Bot ${token}`,
911
+ "content-type": "application/json",
912
+ },
913
+ body: JSON.stringify({ name, auto_archive_duration: 1440 }),
914
+ }, deps);
915
+ if (!res.ok) {
916
+ // 403 = missing Create Public Threads / Send Messages in Threads; 429 = rate limit; etc.
917
+ log(`Discord create-thread failed (${res.status}) for channel ${channelId} — falling back to inline`);
918
+ return null;
919
+ }
920
+ const body = (await res.json());
921
+ const id = isObject(body) ? str(body["id"]) : undefined;
922
+ if (!id) {
923
+ log(`Discord create-thread returned no thread id for channel ${channelId} — falling back to inline`);
924
+ return null;
925
+ }
926
+ return id;
927
+ }
928
+ catch (err) {
929
+ // Threading is fail-OPEN: a null degrades the turn to an inline reply. An exhausted 429 is still
930
+ // named, so the degradation is never a silent drop.
931
+ if (err instanceof RateLimitExhaustedError) {
932
+ log(`Discord create-thread rate limited for channel ${channelId}: gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms) — falling back to inline`);
933
+ return null;
934
+ }
935
+ log(`Discord create-thread error for channel ${channelId}: ${err?.message ?? String(err)}`);
936
+ return null;
937
+ }
938
+ }
939
+ /**
940
+ * Compute the dispatch surface for a turn, given the thread-create outcome. This is the
941
+ * RE-KEY: when a thread was created (threadId non-null), the ENTIRE turn moves onto the thread
942
+ * channel id — identity/auth, continuation token, reply-target state, and engagement key all
943
+ * point at the thread, so the conversation lives there (its own memory silo + durable session)
944
+ * and the base channel is only where the mention landed. When no thread was created (threadId
945
+ * null — either not auto-threadable or a fail-open fallback), the turn stays on the base
946
+ * channel exactly as today. Pure → the fail-open contract is unit-tested by passing an id / null
947
+ * without any live Discord call.
948
+ */
949
+ export function dispatchSurfaceFor(msg, baseAuth, threadId) {
950
+ if (threadId) {
951
+ // Re-key onto the thread. channelType "11" = PUBLIC_THREAD, so the identity seam derives a
952
+ // guild `channel` surface with its own surfaceKey (discord/<guild>/<threadId>).
953
+ // parentChannelId is FREE here — the thread hangs off the mention's own base channel
954
+ // (msg.channelId), no REST needed — and it keeps the session's DOMAIN/SPEND grain on
955
+ // that channel (binding.domainSurface → partition.domainKey) while the surfaceKey /
956
+ // session / engagement stay on the thread (item 22).
957
+ // NO `messageId` here, deliberately (OPE-671): this REKEYS `channelId` onto the newly-created
958
+ // thread, but `msg.id` still lives in the BASE channel (the mention that spawned the thread) —
959
+ // pairing them would address a reaction at a message the thread channel doesn't own. The 👀
960
+ // swap-tracked reaction has the identical constraint (see `original: {channelId: msg.channelId,
961
+ // messageId: msg.id}` below, which stays on the BASE channel for the same reason). A turn
962
+ // re-keyed onto a fresh auto-thread therefore gets no ✋ — a narrower, PRE-EXISTING gap (today
963
+ // NO Discord turn gets one), not a regression.
964
+ const auth = buildDiscordAuth({
965
+ userId: msg.author.id,
966
+ guildId: msg.guildId,
967
+ channelId: threadId,
968
+ channelType: "11",
969
+ parentChannelId: msg.channelId,
970
+ });
971
+ return { auth, continuationToken: `discord:${threadId}`, state: { channelId: threadId }, engagementKey: threadId };
972
+ }
973
+ // No thread → base channel, identical to the pre-auto-thread behaviour.
974
+ return {
975
+ auth: baseAuth,
976
+ continuationToken: `discord:${msg.channelId}`,
977
+ state: { channelId: msg.channelId },
978
+ engagementKey: msg.channelId,
979
+ };
980
+ }
981
+ /**
982
+ * Translate the shared finalMessageAction decision into the concrete completion operations. Pure —
983
+ * no I/O, no env, fully unit-tested.
984
+ * - post → POST the reply fresh (split to Discord's 2000-char cap); swap 👀→✅ when this turn
985
+ * placed a swap-tracked reaction (auto-threaded mention).
986
+ * - silent → post nothing; clear the 👀 if one was swap-tracked (don't leave "seen, working"
987
+ * standing on a mention the agent decided not to answer).
988
+ * - buffer-tool-status → interim tool turn, not the final reply → do nothing at all.
989
+ * `reactionActive` = this turn seeded original-message coords (status UX on + auto-threaded).
990
+ * The non-threaded 👀 (DMs, in-thread mentions) is a plain "seen" ack, deliberately NOT swapped
991
+ * or cleared — the answer lands right under the message, so the ack can simply stand.
992
+ */
993
+ export function planCompletion(input) {
994
+ const { action, reactionActive, cardPostedThisTurn } = input;
995
+ if (action.kind === "buffer-tool-status") {
996
+ // Not the final message — an interim tool-call turn. Touch nothing; the real reply comes later.
997
+ return { reaction: "none", delivery: { kind: "none" } };
998
+ }
999
+ if (action.kind === "post") {
1000
+ return {
1001
+ reaction: reactionActive ? "clear" : "none",
1002
+ delivery: cardPostedThisTurn ? { kind: "none" } : { kind: "post", chunks: splitForDiscord(action.text) },
1003
+ };
1004
+ }
1005
+ // action.kind === "silent" (empty final message OR the NO_REPLY sentinel — judged not warranted).
1006
+ return {
1007
+ reaction: reactionActive ? "clear" : "none",
1008
+ delivery: { kind: "none" },
1009
+ };
1010
+ }
1011
+ /**
1012
+ * Assemble the session state SEEDED into `send` for a turn. This seed only takes effect when
1013
+ * the send STARTS a session (eve drops it on the resume path — see the session-state comment
1014
+ * up top), which is exactly why `original` is only ever passed on the auto-thread turn: a fresh
1015
+ * thread id guarantees a fresh session, so the seeded reaction coords are guaranteed to belong
1016
+ * to THIS turn's triggering message. The reply surface (`channelId`) follows the re-key (the
1017
+ * thread when threaded); the reaction target stays pinned to the mention in the BASE channel so
1018
+ * 👀/✅ land where the human is looking. No original coords → completion does no reaction swap.
1019
+ */
1020
+ export function buildGatewayState(input) {
1021
+ return {
1022
+ channelId: input.replyChannelId,
1023
+ guildId: input.guildId,
1024
+ originalChannelId: input.original?.channelId,
1025
+ originalMessageId: input.original?.messageId,
1026
+ };
1027
+ }
1028
+ /**
1029
+ * Consume this turn's reaction-swap coordinates from the LIVE session state: return the
1030
+ * original-message coords and CLEAR them (consume-once). The clearing is the correctness
1031
+ * guarantee on resumed sessions — a later turn can never react on a PREVIOUS turn's message;
1032
+ * the coords only ever exist on the session-creating auto-thread turn. Mutates the passed
1033
+ * state in place (eve write-back persists it). Pure in/out mapping → unit-tested.
1034
+ */
1035
+ export function takeTurnStatus(state) {
1036
+ const original = state.originalChannelId && state.originalMessageId
1037
+ ? { channelId: state.originalChannelId, messageId: state.originalMessageId }
1038
+ : undefined;
1039
+ state.originalChannelId = undefined;
1040
+ state.originalMessageId = undefined;
1041
+ return { original };
1042
+ }
1043
+ // UNIFIED grain for a Discord surface's DOMAIN-grain key (a thread asks its parent channel's
1044
+ // domain — domainKeyOf, the same grain the nameDropWake and spend gates use). FAIL-SOFT to the
1045
+ // per-container grain (the byte-identical path): a config-load error must degrade to today's
1046
+ // behaviour, never take the channel down or mis-key a session. Mirrors github.ts githubGrainFor.
1047
+ function discordGrainFor(domainKey) {
1048
+ try {
1049
+ return supervisorGrainFor(domainKey, getConfig().loadDomains());
1050
+ }
1051
+ catch {
1052
+ return { unified: false, registryKey: domainKey };
1053
+ }
1054
+ }
1055
+ // The ambient-calibration dial for a diverted watch wake (memory/domain.ts engagementTierFor,
1056
+ // resolved at the DOMAIN grain). Fail-soft: neutral. Mirrors github.ts githubEngagementTier.
1057
+ function discordEngagementTier(domainKey) {
1058
+ try {
1059
+ return engagementTierFor(domainKey, getConfig().loadDomains());
1060
+ }
1061
+ catch {
1062
+ return undefined;
1063
+ }
1064
+ }
1065
+ /**
1066
+ * THE DISCORD PRIVACY FLOOR (fail-closed): may this surface join a PRIVATE cross-surface window?
1067
+ *
1068
+ * Every single-window surface needs a POSITIVE privacy fact before its wakes may share a context
1069
+ * window with the domain's private Slack/GitHub/Linear/Notion conversations (GitHub:
1070
+ * `repository.private === true`; Slack: the channel positively confirmed internal; Notion: the
1071
+ * operator-vouch register). Discord's honest answer is that NO reliable positive signal exists
1072
+ * in what reaches this gateway:
1073
+ * - The guild is the tenant boundary (external people aren't guild members), but guilds are
1074
+ * routinely PUBLIC/community servers with open invites — guild membership alone proves
1075
+ * nothing about who can read or post.
1076
+ * - The relay's MESSAGE_CREATE payload carries NO channel-permission data. The identity
1077
+ * layer's `everyoneCanView` field (discord-identity.ts) is documented "absent today" — and
1078
+ * its `discordVisibility` default of `private` is the WRONG direction here: that default is
1079
+ * conservative for MEMORY (learnings stay surface-tier), but for window ADMISSION
1080
+ * "default-private" would be permissive — it would admit every guild channel, including
1081
+ * @everyone-viewable channels in public community guilds. A default is not a positive fact.
1082
+ * So, exactly like Notion (whose public_url was similarly misleading), the floor is the
1083
+ * OPERATOR-VOUCH register (identity/trust.ts isVouchedInternal — the deployer's out-of-band
1084
+ * "this guild/channel is members-only internal" assertion, keyed on the workspaceKey
1085
+ * `discord/<guild>` or a surfaceKey `discord/<guild>/<channel>`; a thread also honours a vouch
1086
+ * on its PARENT channel via the domain-grain key), or the OE_TRUST=internal blanket switch for
1087
+ * internal-only deploys. Code enforces, the operator vouches — never an inference from a payload
1088
+ * field. Fail-CLOSED: un-vouched ⇒ false ⇒ the wake stays on the native per-channel path.
1089
+ *
1090
+ * DMs / group-DMs (no guildId) NEVER pass — unconditionally, even under OE_TRUST=internal
1091
+ * (mirroring how the e2e driver allowlist excludes guildless surfaces): a DM is a PERSONAL
1092
+ * surface with no tenant boundary at all, and its content must never enter a team-shared window.
1093
+ *
1094
+ * ATTACKER ANALYSIS (what goes wrong if this rule is too loose): if a public/@everyone-readable
1095
+ * channel were admitted, anyone who can join the guild (open invite) could (a) READ private
1096
+ * cross-surface context — an @mention here would recall, into a public channel, whatever the
1097
+ * shared window absorbed from the domain's private repos/channels/docs — and (b) WRITE into the
1098
+ * shared context: their message text enters the one window every surface's supervisor judges
1099
+ * from, a persistent cross-surface prompt-injection foothold. The vouch register makes admission
1100
+ * an explicit operator assertion with exactly that blast radius in view.
1101
+ *
1102
+ * Pure (vouch + env injectable) → the truth table is unit-tested without a live register
1103
+ * (scripts/unified-window-test.ts). Mirrors notion.ts's floor + github.ts's positive-privacy gate.
1104
+ */
1105
+ export function discordSingleWindowPrivacyOk(input, deps = {}) {
1106
+ if (!input.guildId)
1107
+ return false; // DM/group-DM: NEVER, unconditionally (no tenant boundary)
1108
+ const vouched = deps.vouched ?? isVouchedInternal;
1109
+ const trustEnv = "trustEnv" in deps ? deps.trustEnv : process.env.OE_TRUST;
1110
+ if (trustEnv === "internal")
1111
+ return true; // the blanket internal-only-deploy switch
1112
+ if (vouched(input.workspaceKey, input.surfaceKey))
1113
+ return true;
1114
+ // A thread inherits its parent channel's vouch (the domain-grain key — same widening the
1115
+ // nameDropWake/spend gates apply; a vouch names channels, threads come and go under them).
1116
+ return input.domainKey !== undefined && input.domainKey !== input.surfaceKey && vouched(input.workspaceKey, input.domainKey);
1117
+ }
1118
+ /**
1119
+ * The Discord arm of the ONE single-window mention decision — a thin shim over the shared seam
1120
+ * (unified-window.ts {@link singleWindowMentionPolicy}), exactly like github.ts's
1121
+ * shouldSuppressNativeMention and slack-events.ts's slackSingleWindowMentionPolicy:
1122
+ * `suppressNative` ⟺ the divert owns the @mention (this file's native dispatch — auto-thread,
1123
+ * 👀, typing, per-channel session — stands down); `addressing` is the `direct-mention` stamp the
1124
+ * diverted wake carries so the unified supervisor knows it is owed an answer — which it must give
1125
+ * through the `reply` tool, since OPE-1982 deleted the prose route. Both derive from the SAME predicate (`singleWindow && privacyOk && isMention`), so
1126
+ * suppress-and-keep stay perfectly complementary: never a double reply, never a gap. `privacyOk`
1127
+ * is {@link discordSingleWindowPrivacyOk}'s verdict (fail-closed). Pure → unit-tested truth table.
1128
+ */
1129
+ export function discordSingleWindowMentionPolicy(singleWindow, isMention, privacyOk) {
1130
+ return singleWindowMentionPolicy(singleWindow, isMention, privacyOk);
1131
+ }
1132
+ /**
1133
+ * Build the NormalizedWake for one DIVERTED Discord wake — the exact shape
1134
+ * buildGitHubSupervisorWake produces for GitHub, with Discord's locus mapping: container =
1135
+ * the (parent) channel, conversation = the thread when the trigger was in one. Always stamps
1136
+ * the platform address + P5 disclosure + wake-id (this builder exists ONLY for the
1137
+ * single-window path — the native dispatch renders via renderTurn/renderNameDropTurn,
1138
+ * untouched). renderWake then emits the `{message, context}` every OE dispatch sends — the
1139
+ * SAME renderer Slack/GitHub/Linear/Notion use, with NO platform branch. Pure → unit-testable.
1140
+ */
1141
+ export function buildDiscordUnifiedWake(input) {
1142
+ const direct = input.addressing === "direct-mention";
1143
+ return {
1144
+ platform: "discord",
1145
+ // An addressed @mention is reason "mention" (honest, rides the envelope); engaged/name-drop
1146
+ // chatter is "channel-activity". Every addressing here keeps the full envelope, so the
1147
+ // wake-id survives for the reply tool (the ONLY reply route on the unified channel).
1148
+ reason: direct ? "mention" : "channel-activity",
1149
+ addressing: input.addressing,
1150
+ // The ambient calibration dial applies only to the watch wake (an addressed mention is owed
1151
+ // an answer; a name-drop has its own higher-bar preamble) — mirrors the GitHub builder.
1152
+ engagementTier: input.addressing === "channel-watch" ? input.engagementTier : undefined,
1153
+ // The ENROLMENT fact, through the ONE shared helper every enveloped surface calls
1154
+ // (channels/engaged-wake.ts, OPE-1740). `nameDrop` below still wins in renderWake, so a
1155
+ // name-drop wake's own mechanic is unchanged.
1156
+ followUp: engagedFollowUpContract({ addressing: input.addressing, threadEngaged: input.threadEngaged }),
1157
+ wakeId: input.wakeId,
1158
+ locus: {
1159
+ // Container = the channel (a thread's PARENT when known — the grain domains/spend use).
1160
+ channel: { id: input.parentChannelId ?? input.channelId },
1161
+ // Conversation = the thread (its own channel id) when the trigger was in one.
1162
+ ...(input.isThread ? { thread: { ts: input.channelId } } : {}),
1163
+ },
1164
+ // The `botName` guard is a FIDELITY guard, not a data dependency (OPE-770 removed the field the
1165
+ // block used to carry): a relay that reported no bot name never matched one, so it gets no
1166
+ // `follow-up` attribute — exactly the bytes it rendered before.
1167
+ ...(input.addressing === "name-drop" && input.botName
1168
+ ? {
1169
+ nameDrop: {
1170
+ // The honest follow-up contract, mirroring the native path's engagement mechanics
1171
+ // (dispatchTurn): a THREAD name-drop engaged the thread at dispatch; a base-channel
1172
+ // name-drop deliberately does not follow (the engaged-channel surprise).
1173
+ followUp: input.isThread ? "thread-engaged" : "channel-not-following",
1174
+ },
1175
+ }
1176
+ : {}),
1177
+ backscroll: input.tail && input.tail.length > 0 ? input.tail : undefined,
1178
+ // The hydration's own slot (OPE-1749): `renderWake` de-duplicates it against `backscroll` above
1179
+ // and caps below that, so this road renders through the same core ordering as every other surface.
1180
+ ...(input.contextLines && input.contextLines.length > 0
1181
+ ? { contextLines: input.contextLines, ...(input.contextSource === undefined ? {} : { contextSource: input.contextSource }) }
1182
+ : {}),
1183
+ ...(input.catchUpUnavailable !== undefined ? { catchUpUnavailable: input.catchUpUnavailable } : {}),
1184
+ trigger: mkMessageTrigger({
1185
+ from: "human", // bot authors were dropped at ingress (authForRelayMessage)
1186
+ author: input.trigger.author,
1187
+ authorId: input.trigger.authorId,
1188
+ id: input.trigger.id,
1189
+ sentAt: input.trigger.sentAt,
1190
+ // The caller's stated per-person tier — no default (OPE-596).
1191
+ trust: input.trigger.trust,
1192
+ // The @mention response-obligation flag (rendered `mention="true"`).
1193
+ mention: direct ? true : undefined,
1194
+ body: input.trigger.text,
1195
+ }),
1196
+ };
1197
+ }
1198
+ /**
1199
+ * Build the NormalizedWake for one NATIVE (non-diverted) Discord wake — the per-channel-session
1200
+ * path that renderTurn/renderNameDropTurn used to hand-render. It maps into the SAME shared type
1201
+ * every other surface uses, so renderWake emits the one canonical dialect with NO platform branch:
1202
+ * - `direct` (DM / @mention): the conversation driver — renderWake returns the body VERBATIM (no
1203
+ * preamble, no envelope, unwrapped — W4), byte-identical to the old renderTurn(true).
1204
+ * - `thread-follow` (engaged non-mention) / `name-drop`: JUDGED — the canonical Slack-parity
1205
+ * preamble + the full wake envelope (the migration's deliberate convergence; the old Discord
1206
+ * prose stub had DRIFTED away from the social-chatter / never-re-answer / PREFER_NO_REPLY_TOOL
1207
+ * sentences, each of which encodes an observed judge failure).
1208
+ * Locus: container = the (parent) channel; conversation = the thread when the trigger was in one.
1209
+ * No wakeId is minted (a native wake answers through the base `reply`, which names no wake). Pure →
1210
+ * unit-testable without a live Discord call.
1211
+ */
1212
+ export function discordNativeWake(input) {
1213
+ const direct = input.addressing === "direct";
1214
+ return {
1215
+ platform: "discord",
1216
+ // Direct is DM or @mention (reason is cosmetic — the direct render carries no envelope); a
1217
+ // judged wake's reason honestly reflects thread vs base-channel activity.
1218
+ reason: direct ? (input.isDm ? "dm" : "mention") : input.isThread ? "thread-activity" : "channel-activity",
1219
+ addressing: input.addressing,
1220
+ // The ENROLMENT fact, through the ONE shared helper (channels/engaged-wake.ts, OPE-1740), off the
1221
+ // engagement row the dispatch already read — never off the `thread-follow` tier, which a warm
1222
+ // name-drop latch also reaches. `direct` renders no envelope and `nameDrop` wins in renderWake,
1223
+ // so both are unchanged.
1224
+ followUp: engagedFollowUpContract({ addressing: input.addressing, threadEngaged: input.threadEngaged }),
1225
+ locus: {
1226
+ // Container = the channel (a thread's PARENT when known — the grain domains/spend use).
1227
+ channel: { id: input.parentChannelId ?? input.channelId },
1228
+ // Conversation = the thread (its own channel id) when the trigger was in one.
1229
+ ...(input.isThread ? { thread: { ts: input.channelId } } : {}),
1230
+ },
1231
+ // The `botName` guard is a FIDELITY guard, not a data dependency (OPE-770 removed the field the
1232
+ // block used to carry): a relay that reported no bot name never matched one, so it gets no
1233
+ // `follow-up` attribute — exactly the bytes it rendered before.
1234
+ ...(input.addressing === "name-drop" && input.botName
1235
+ ? {
1236
+ nameDrop: {
1237
+ // The honest follow-up contract, matching the native engagement mechanics dispatchTurn
1238
+ // applied: a THREAD name-drop engaged the thread at dispatch (follow-ups flow); a
1239
+ // base-channel name-drop deliberately does NOT follow (the engaged-channel surprise).
1240
+ followUp: input.isThread ? "thread-engaged" : "channel-not-following",
1241
+ },
1242
+ }
1243
+ : {}),
1244
+ ...(input.contextLines ? { contextLines: input.contextLines, contextSource: input.contextSource } : {}),
1245
+ trigger: mkMessageTrigger({
1246
+ from: "human", // bot authors were dropped at ingress (authForRelayMessage / the wake gate)
1247
+ author: input.trigger.author,
1248
+ authorId: input.trigger.authorId,
1249
+ id: input.trigger.id,
1250
+ sentAt: input.trigger.sentAt,
1251
+ // The caller's stated per-person tier — no default (OPE-596).
1252
+ trust: input.trigger.trust,
1253
+ // The @mention response-obligation flag renders only on the enveloped judged paths; a raw
1254
+ // `direct` body carries no envelope so the flag never surfaces (left unset there).
1255
+ mention: undefined,
1256
+ body: input.trigger.text,
1257
+ }),
1258
+ };
1259
+ }
1260
+ /**
1261
+ * THE 1:1 (and group) DM WAKE for a first-classed Discord DM window (OPE-356) — the Discord twin of
1262
+ * slack-events.ts's `slackDmWake`, and it renders through the SAME core renderer with no platform
1263
+ * branch, so the two surfaces' DM envelopes are one dialect.
1264
+ *
1265
+ * ADDRESSING IS `direct-mention`, ALWAYS, doing the same two jobs it does on Slack. Semantically it
1266
+ * is simply TRUE: everything said in a DM is said to the agent, which is why this path bypasses the
1267
+ * engagement gate, the name-drop judge and the channel-watch silence bias entirely — a DM IS ALWAYS
1268
+ * ANSWERED. Mechanically it is LOAD-BEARING: `direct-mention` is what stamps `oe_addressing` through
1269
+ * the divert, which marks the wake as OWED a reply and selects the `[direct]` preamble's reply
1270
+ * mandate. 🔴 IT IS NOT A DELIVERY PATH (OPE-1982): the addressed-prose bridge it used to arm is
1271
+ * deleted, so a DM answer written as prose rather than a `reply` call is DROPPED on that channel
1272
+ * like any other, and re-driven once towards the tool.
1273
+ *
1274
+ * `reason` is `dm`, not `mention`: the envelope's reason describes the SURFACE SHAPE. It is also the
1275
+ * one reason that survives the OPE-744 collapse of `mention` into `channel-activity`, because no
1276
+ * other attribute states "this is a direct-message room".
1277
+ *
1278
+ * NO `engagementTier`: that dial raises the bar for AMBIENT watching, and a DM is always answered.
1279
+ *
1280
+ * The `wakeId` is present because the unified channel is the delivery route: it is the id the model
1281
+ * must echo to `reply`, and the only way a DM answer gets out. Pure → unit-testable.
1282
+ */
1283
+ export function buildDiscordDmWake(input) {
1284
+ return {
1285
+ platform: "discord",
1286
+ reason: "dm",
1287
+ addressing: "direct-mention",
1288
+ wakeId: input.wakeId,
1289
+ // A DM's container IS the DM channel: no parent, no thread, and — unlike the cross-surface
1290
+ // builder — no `platform` address stamp, because this window sees exactly one surface.
1291
+ locus: { channel: { id: input.channelId } },
1292
+ backscroll: input.tail && input.tail.length > 0 ? input.tail : undefined,
1293
+ trigger: mkMessageTrigger({
1294
+ from: "human", // bot authors are dropped at ingress (authForRelayMessage)
1295
+ author: input.trigger.author,
1296
+ authorId: input.trigger.authorId,
1297
+ id: input.trigger.id,
1298
+ sentAt: input.trigger.sentAt,
1299
+ // The caller's stated per-person tier — no default (OPE-596).
1300
+ trust: input.trigger.trust,
1301
+ // The response-obligation flag, exactly as the Slack DM wake carries it.
1302
+ mention: true,
1303
+ body: input.trigger.text,
1304
+ }),
1305
+ };
1306
+ }
1307
+ /**
1308
+ * THE DM WINDOW DIVERT (OPE-356) — a Discord DM lands in its OWN first-class standing window
1309
+ * (channels/dm-seat.ts), the Discord parity of slack-front.ts's `scheduleDmWake`.
1310
+ *
1311
+ * WHAT IT FIXES. Until now a Discord DM ran on eve's per-channel continuation token: a session with
1312
+ * no window row, and therefore NO DISPATCHABLE ADDRESS. A DM that delegated could not be told the
1313
+ * answer had arrived — `resolveCallerSeat` took the OPE-306 ANCHOR arm, both completion-wake sites
1314
+ * skipped the wake fail-closed, and the workaround was the worker posting its own report into the
1315
+ * DM. A window token is an address, so the DM seat now gets the whole standing-seat apparatus for
1316
+ * free: woken, redriven, rotated (on the DM thresholds — `OE_DM_SEAT_ROTATE_*`, never the
1317
+ * supervisor's), spend-counted and audited through the seams every other seat already used. The
1318
+ * worker's direct report-back drops with it, deliberately: it compensated for a seat that could not
1319
+ * be woken, and keeping it would put two voices in one DM per delegated turn.
1320
+ *
1321
+ * 🔴 IT BYPASSES THE ENGAGEMENT GATE AND THE NAME-DROP JUDGE — see {@link buildDiscordDmWake}. The
1322
+ * SPEND gate is NOT bypassed: `dispatchTurn` has already run `ingressSpendGate` (door `mention`)
1323
+ * before reaching here, and `divertToUnifiedWindow` runs its own attended-arrival gate on top.
1324
+ *
1325
+ * THE ENVELOPE CARRIES NO BACKSCROLL, on purpose and unchanged from the native path (`!isDm && …`
1326
+ * in dispatchTurn): the 1:1 history IS the conversation and the seat's own long-lived session
1327
+ * already holds it. The one thing a FRESH generation carries is the recorded tail of the
1328
+ * generation it replaced (OPE-794).
1329
+ *
1330
+ * FAIL-CLOSED: a seat-resolution or dispatch failure PROPAGATES to the route's `waitUntil` catch.
1331
+ * There is deliberately no fall-back to the native send — that would answer the human from a second
1332
+ * session with no window row behind it, which is the very topology this replaces.
1333
+ */
1334
+ async function divertDiscordDmToWindow(input) {
1335
+ const { msg } = input;
1336
+ const org = deploymentOrg();
1337
+ const atIso = new Date().toISOString();
1338
+ const surfaceKey = input.partition.surfaceKey;
1339
+ // The seat: create / rotate / resume the DM's own standing window row, keyed on the SURFACE KEY
1340
+ // (what the token and the row are both keyed on, and what both admission gates match byte-exactly
1341
+ // to fence a DM window to its own DM). NO `legacyChannelId`: the OPE-90 `oe-dm-seat::<id>` rows
1342
+ // only ever existed on Slack, so there is nothing for the adopt-from-legacy probe to find here —
1343
+ const seat = await getOrCreateDmSeat(org, { surfaceKey }, atIso);
1344
+ // PER-WAKE TRUST off the SAME auth the session binds with; `principalFallback` because a DM
1345
+ // partner is the seat's principal (the native DM path's `direct` default, preserved).
1346
+ const trust = resolveWakeTrustFromContext(ctxFromAuth(input.auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true });
1347
+ const wakeId = mintWakeId();
1348
+ // The frozen reply target: the DM channel itself, host-extracted from the verified relay payload.
1349
+ // `dm: true` is the POSITIVE discriminant OPE-315 added — never a guildless guild-shape — so this
1350
+ // is precisely the origin the registered Discord codec's DM branch admits, and there is no path
1351
+ // from it to guild authority (no guildId, no autoThreadName: the codec forbids both, and the
1352
+ // reply leg's auto-thread branch writes guild-keyed engagement rows).
1353
+ //
1354
+ // 🔴 NO `messageId`, DELIBERATELY. It is codec-legal (cosmetic `message_reference` anchoring with
1355
+ // no guild-shaped consequence), but the native DM path replied with a PLAIN post, and anchoring
1356
+ // every answer to the question it answers is a visible change to a 1:1 conversation that this
1357
+ // ticket is not the place to make. Same reasoning as the Slack DM divert setting no
1358
+ // `replyAnchorTs`: behaviour preserved across the re-seat, not quietly improved.
1359
+ const origin = { platform: "discord", channelId: msg.channelId, dm: true };
1360
+ const rendered = renderWake(buildDiscordDmWake({
1361
+ channelId: msg.channelId,
1362
+ wakeId,
1363
+ ...(seat.freshGeneration && seat.tail.length > 0 ? { tail: seat.tail } : {}),
1364
+ trigger: {
1365
+ id: msg.id || "unknown",
1366
+ author: msg.author.username ?? msg.author.id,
1367
+ authorId: msg.author.id,
1368
+ sentAt: atIso,
1369
+ text: msg.content,
1370
+ trust,
1371
+ },
1372
+ }));
1373
+ try {
1374
+ await divertToUnifiedWindow(input.receive, {
1375
+ message: rendered.message,
1376
+ ...(rendered.context && rendered.context.length > 0 ? { context: rendered.context } : {}),
1377
+ token: seat.token,
1378
+ // `vendorTs` (OPE-1716): the message snowflake's embedded creation time — Discord's own
1379
+ // clock to the millisecond, and the first latency anchor. Absent id ⇒ no reading.
1380
+ window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(discordVendorEventTime(msg.id)) },
1381
+ // Byte-exactly the surface named inside the token — the self-consistency both admission gates
1382
+ // match on, and the whole privacy fence (a DM window is deliverable to ITS OWN DM and nowhere
1383
+ // else). NOT domainKeyOf: a DM has no parent container to widen to.
1384
+ surfaceKey,
1385
+ // OPE-466 CARRIAGE: one ingested message, so the join runs over the trigger's own tier.
1386
+ // OPE-489: the author leg now reads the POSITIVE refutation, and a DM has no room leg at all
1387
+ // (`dm: true` — on a DM the trust plane asks about the PARTNER, not about the room). Discord
1388
+ // cannot refute a DM partner: `member.pending` is a GUILD screening fact and a DM has no guild.
1389
+ auth: stampNonMemberChatAuth(input.auth, [{ trust }], { dm: true }),
1390
+ });
1391
+ }
1392
+ catch (err) {
1393
+ // OPE-970: this DM divert has NO re-delivery leg (unlike slack-front.ts's DM route) and, until
1394
+ // now, NO audit row either — the outer `dispatchTurn(...).catch` at the route only logged the
1395
+ // error to the console. `auditSupervisorFailure` (the same generic writer github.ts/linear.ts
1396
+ // already use for their own lifecycle failures) gives it the SAME `surface.wake_failed` row
1397
+ // Slack/Teams already write for their own wake failures, then rethrows unchanged.
1398
+ // `surface: surfaceKey` (the compound `discord/user/channel`), matching this file's own
1399
+ // dominant convention for a wake-shaped audit row (line ~2112/2259/2330 below), not the raw
1400
+ // `partition.surface` channel id.
1401
+ await auditSupervisorFailure({ org, platform: input.partition.platform, workspace: input.partition.workspace, surface: surfaceKey }, "surface.wake_failed", {
1402
+ message: String(err instanceof Error ? err.message : err).slice(0, 500),
1403
+ wakeId,
1404
+ addressing: "direct-mention",
1405
+ });
1406
+ throw err;
1407
+ }
1408
+ // Record the wake on the seat's own row (turnCount/lastWakeAt feed the rotation thresholds),
1409
+ // best-effort — a touch failure must never affect the reply. Through the seat's OWN seam, so the
1410
+ // row it touches cannot drift from the row the mint wrote.
1411
+ await touchDmSeat(org, surfaceKey, atIso).catch(() => undefined);
1412
+ // OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
1413
+ // the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
1414
+ await recordWakeTail(org, dmWindowRegistryKey(surfaceKey), seat.token, {
1415
+ kind: "wake",
1416
+ at: atIso,
1417
+ from: "human", // bot authors are dropped at ingress (authForRelayMessage)
1418
+ trust,
1419
+ // THE AUTHOR GRAIN (OPE-1747) — the same two facts this surface's taint join reads: Discord has
1420
+ // no membership verdict of its own beyond `member.pending`, so the OPERATOR's register query
1421
+ // (`authorFacts`) is the leg that carries the refutation, and it must ride the row.
1422
+ ...tailAuthorGrain({ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }),
1423
+ author: msg.author.username ?? msg.author.id,
1424
+ authorId: msg.author.id,
1425
+ id: msg.id || "unknown",
1426
+ platform: "discord",
1427
+ container: surfaceKey,
1428
+ text: msg.content,
1429
+ });
1430
+ }
1431
+ /**
1432
+ * THE DOMAIN'S ADMISSION POLICY over a mention/name-drop hydration, for BOTH of this surface's wake roads (OPE-1800).
1433
+ * 🔴 THE DOMAIN'S POLICY, ON THIS READ TOO. A seated Discord wake takes TWO reads of the room: the since-last-wake catch-up, which has passed `seatAdmissionFor` since OPE-1726, and this hydration, which did not. So a `members-only` domain dropped a guest's message from the catch-up and then handed the seat the SAME message inside the context block. The policy is resolved by NAME from the caller's own domain key and applied by the shared core seam (channels/seat-admission.ts `admitContextLines`), which is the one mapping Slack's hydration takes — never a per-bundle copy.
1434
+ * 🔴 THE SEAT DECIDES, AND THE SEAT IS `grainUsesUnifiedWindow(grain)` ALONE — never "which road this wake took". That is the exact predicate Slack resolves `seated` from ({@link slackDomainUsesUnifiedWindow}, "the SAME fact … without the async privacy floor"), and matching it is what gives one domain ONE admission answer. A wake whose room HAS a seat but which falls through to `dispatchTurn`'s native road — the guild vouch absent, or no `receive` — is a seat STOOD DOWN, not an unseated room: access/window-routing.ts calls that state `native-degraded` and hooks/window-routing.ts audits it `window.routing_degraded`. Reading it as "no seat" would hand that domain a second admission answer, selected by a runtime vouch.
1435
+ * 🔴 A TRULY UNSEATED DEPLOYMENT KEEPS ITS WHOLE HYDRATION: `window.topology` unarmed, or no seat domain reaches the room, so `grainUsesUnifiedWindow` is false. There is no seat for a seat policy to govern, that hydration is the only context such a wake carries, and the shipped `wide` default would strip the attributable-bot lines it keeps for coherence.
1436
+ * 🔴 GRADE FIRST, THEN ADMIT. `lines` must already carry the per-author regrade ({@link gradeDiscordContextLines}): the policy judges the RESOLVED tier, and an ungraded person floors at `unauth`, so admitting first would make `members-only` refuse every human line in the room.
1437
+ * Nothing admitted ⇒ `undefined`, the same shape an absent fetch produces — never an empty slot, which renders as a quiet room.
1438
+ */
1439
+ export function admitDiscordSeatLines(lines, grain, domainKey, isThread) {
1440
+ if (!lines || lines.length === 0)
1441
+ return undefined;
1442
+ // The unseated arm hands the SAME array back, never a copy: this road was byte-identical before
1443
+ // OPE-1800 and must stay so.
1444
+ if (!grainUsesUnifiedWindow(grain))
1445
+ return lines;
1446
+ const admitted = admitContextLines(lines, seatAdmissionFor(domainKey), isThread ? "thread" : "channel");
1447
+ return admitted.length > 0 ? admitted : undefined;
1448
+ }
1449
+ /**
1450
+ * The WINDOW road's use of {@link admitDiscordSeatLines}: admit, then derive the taint-join facts — both over ONE set.
1451
+ * 🔴 IT NO LONGER RENDERS (OPE-1749). It used to hand `divertDiscordWakeToUnifiedWindow` a PRE-RENDERED string, which took this road OUT of the core render — and with it out of the envelope de-duplication and the cap ORDER that `renderWake` applies through `hydrationSlices`. A seat whose carried TAIL overlapped this hydration then rendered each overlapping message TWICE and spent the ten-line cap doing it. The road now hands the structured lines down to the builder, exactly as every other surface does, and core decides what renders.
1452
+ * 🔴 ONE SET FEEDS BOTH OUTPUTS, so the join can never carry a line the policy REFUSED — which is the property that matters, because a refused line's bytes never reach the model. It is not an equality of counts: the core render applies its own bounds and the de-duplication drops the envelope's own copies, so FEWER lines render than the join marks. That direction over-taints, which is the safe one (OPE-475) — and a de-duplicated line's bytes did enter, through the envelope sibling that replaced it.
1453
+ * Nothing admitted ⇒ EMPTY on both, the same shape an absent or empty fetch produces, never an empty context block.
1454
+ */
1455
+ export function discordSeatContextForWindow(lines, source, grain, domainKey, isThread) {
1456
+ const admitted = admitDiscordSeatLines(lines, grain, domainKey, isThread);
1457
+ if (!admitted)
1458
+ return { lines: [], ingestion: [] };
1459
+ return { lines: admitted, ...(source === undefined ? {} : { source }), ingestion: admitted.map(contextLineIngestion) };
1460
+ }
1461
+ /**
1462
+ * Divert one Discord wake into the unified window: get-or-create the DOMAIN session (the SAME
1463
+ * registry row + rotation policy every other surface's live deps drive — github.ts
1464
+ * liveGitHubSupervisorDeps — so a Discord wake lands in whatever generation the domain is on),
1465
+ * build + render the wake with a host-minted wake-id, and hand it to the shared
1466
+ * divertToUnifiedWindow (which stamps the auth and registers the durable wakeId→origin map
1467
+ * entry BEFORE the send, inside unified.ts's fail-closed receive).
1468
+ *
1469
+ * 🔴 NOTHING HERE REACTS TO A SESSION THAT DIES AT TURN TIME, and nothing can: the dispatch awaits
1470
+ * only the ENQUEUE, so a turn-time throw cannot reject it, and eve emits no userland event for the
1471
+ * unreadable-World class (channels/session-resume.ts holds the measurement). This comment used to
1472
+ * promise a force-rotate-and-re-deliver arm; OPE-174 removed that arm as provably inert. The
1473
+ * remedy that does work is PRE-DISPATCH — the OPE-599 runtime-change claim below, which rotates
1474
+ * BEFORE the wake goes out so the trigger lands in a session the running eve build can read.
1475
+ */
1476
+ /**
1477
+ * EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `dispatchTurn` is the
1478
+ * only production caller and it is one file away. `scripts/owned-thread-ingress-wiring-test.ts`
1479
+ * drives THIS function — the real Discord ingress divert, over the real store — because a test that
1480
+ * reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
1481
+ * which is the exact regression the ticket closes.
1482
+ */
1483
+ export async function divertDiscordWakeToUnifiedWindow(input) {
1484
+ const { msg, grain } = input;
1485
+ const org = deploymentOrg();
1486
+ const store = getOperationalStore();
1487
+ const atIso = new Date().toISOString();
1488
+ // ENGAGE an addressed THREAD wake at dispatch — the native path's exact affordance (its
1489
+ // dispatch marks the arriving surface engaged) that the divert otherwise skips, so a mention
1490
+ // inside an existing thread (incl. a post-stop revive) keeps its non-mention follow-ups
1491
+ // flowing. Base channels deliberately excluded: one mention must not turn a whole channel
1492
+ // into a followed surface — a base-channel mention's follow surface is the auto-thread the
1493
+ // reply leg opens (engaged there, unified-reply.ts). Best-effort, never blocks the wake.
1494
+ if (input.addressing === "direct-mention" && isThreadChannelType(msg.channelType)) {
1495
+ await markThreadEngaged(input.partition, msg.channelId, atIso, { participating: true });
1496
+ }
1497
+ const domainName = grain.domainName ?? "unknown"; // caller-guaranteed present; belt-and-braces
1498
+ const tokenFor = (gen) => mintRotationToken(grain, gen, () => unifiedSupervisorToken(domainName, gen));
1499
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the mint
1500
+ // arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
1501
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: input.partition.surfaceKey, domainName: grain.domainName, atIso });
1502
+ // Get-or-create the domain session (H7c self-heal semantics mirrored from github.ts: a resume
1503
+ // whose first wake never delivered is still "fresh", so the next wake re-carries context).
1504
+ const existing = await store.getChannelSession(org, grain.registryKey);
1505
+ let session;
1506
+ if (!existing) {
1507
+ const row = await store.rotateChannelSession(org, grain.registryKey, tokenFor(1), atIso);
1508
+ // OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
1509
+ // wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
1510
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: input.partition.surfaceKey, domainName: grain.domainName, atIso });
1511
+ session = { token: row.token, generation: row.generation, fresh: true, tail: [] };
1512
+ }
1513
+ else if (shouldRotate(existing, atIso, { runtimeChanged })) {
1514
+ // OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor.
1515
+ const tail = await carryTailForward(org, grain.registryKey, existing.token);
1516
+ const row = await store.rotateChannelSession(org, grain.registryKey, tokenFor(existing.generation + 1), atIso);
1517
+ session = { token: row.token, generation: row.generation, fresh: true, tail };
1518
+ }
1519
+ else {
1520
+ // The tail is empty on a resume: it was carried and cleared at the rotation that minted this row.
1521
+ session = { token: existing.token, generation: existing.generation, fresh: existing.firstWakeAt == null, tail: [] };
1522
+ }
1523
+ // PER-WAKE TRUST (identity/wake-trust.ts — audit-wedge F3), off the SAME auth capture the
1524
+ // session binds with. TRIGGER: the message author IS the bound principal.
1525
+ const trust = resolveWakeTrustFromContext(ctxFromAuth(input.auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true });
1526
+ const doWake = async (token, generation, tail, catchUpUnavailable) => {
1527
+ const wakeId = mintWakeId();
1528
+ // The frozen reply target: the EXACT conversation the message arrived in (a thread is its
1529
+ // own channel id, so a threaded trigger's reply threads for free). Host-extracted from the
1530
+ // verified relay payload, never model-authored. guildId is caller-guaranteed by the privacy
1531
+ // floor (DMs never divert) — and re-validated fail-closed by isUnifiedWakeOrigin on both
1532
+ // the register and the resolve side.
1533
+ const origin = {
1534
+ platform: "discord",
1535
+ channelId: msg.channelId,
1536
+ guildId: msg.guildId ?? "",
1537
+ ...(msg.id ? { messageId: msg.id } : {}),
1538
+ // ADDRESSED mention in a base channel ⇒ carry the auto-thread name so the reply leg
1539
+ // opens a thread off the mention and answers there (the native path's auto-thread UX;
1540
+ // the divert skips that block, so without this the answer lands bare in the channel).
1541
+ ...(msg.id && input.addressing === "direct-mention" && shouldAutoThread(msg.channelType, autoThreadEnabled())
1542
+ ? { autoThreadName: deriveThreadName(msg.content) }
1543
+ : {}),
1544
+ // OPE-1719 — THE POSITIVE THREAD SIGNAL the owned-thread ingress router reads. A Discord
1545
+ // thread is its own channel id, so `channelId` alone cannot tell a thread from a plain
1546
+ // channel; the relay payload's channel type can, and it is host-extracted here exactly like
1547
+ // every other field of this frozen origin. Stamped ONLY on a real thread type, so a plain
1548
+ // channel's wake stays top-level and the standing seat keeps it.
1549
+ ...(isThreadChannelType(msg.channelType) ? { thread: true } : {}),
1550
+ };
1551
+ const rendered = renderWake(buildDiscordUnifiedWake({
1552
+ guildId: msg.guildId ?? "",
1553
+ channelId: msg.channelId,
1554
+ parentChannelId: str(input.auth.attributes.parent_channel_id),
1555
+ isThread: isThreadChannelType(msg.channelType),
1556
+ addressing: input.addressing,
1557
+ threadEngaged: input.threadEngaged,
1558
+ wakeId,
1559
+ engagementTier: discordEngagementTier(domainKeyOf(input.partition)),
1560
+ tail,
1561
+ // 🔴 THE HYDRATION RIDES THE WAKE, NOT A PRE-RENDERED STRING (OPE-1749). `renderWake` holds
1562
+ // both slots — these lines and `tail` — so core de-duplicates them against each other and
1563
+ // spends the cap below that de-duplication, the same road every other surface takes.
1564
+ contextLines: input.contextLines,
1565
+ contextSource: input.contextSource,
1566
+ botName: msg.botName,
1567
+ catchUpUnavailable,
1568
+ trigger: {
1569
+ id: msg.id || "unknown",
1570
+ author: msg.author.username ?? msg.author.id,
1571
+ authorId: msg.author.id,
1572
+ sentAt: atIso,
1573
+ text: msg.content,
1574
+ trust,
1575
+ },
1576
+ }));
1577
+ const domainSurfaceKey = domainKeyOf(input.partition);
1578
+ try {
1579
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
1580
+ // route has always used. A wake landing in a thread a live task worker or thread seat OWNS is
1581
+ // dispatched to that owner's token instead of the standing seat's. Flag-off, and on every
1582
+ // unowned thread, it is a byte-identical passthrough to `divertToUnifiedWindow`. The DM leg
1583
+ // above stays on the raw divert on purpose: a Discord DM has no threads, so it can never own
1584
+ // one, and a lookup there would only cost a store read.
1585
+ await divertInboundToUnifiedWindow(input.receive, {
1586
+ message: rendered.message,
1587
+ // ONE SOURCE for the block now: `renderWake` rendered it from `contextLines` above.
1588
+ context: rendered.context ?? [],
1589
+ token,
1590
+ window: {
1591
+ wakeId,
1592
+ origin,
1593
+ // ADDRESSED @mention ⇒ stamp `oe_addressing:"direct-mention"` (via stampUnifiedWindowAuth)
1594
+ // so the unified completion handler bridges the answer to this wake if the model writes
1595
+ // prose instead of calling `reply`. Watch/name-drop wakes omit it (prose stays dropped).
1596
+ ...(input.addressing === "direct-mention" ? { addressing: "direct-mention" } : {}),
1597
+ // `vendorTs` (OPE-1716): the snowflake's embedded creation time — see the DM leg above.
1598
+ ...spreadVendorTs(discordVendorEventTime(msg.id)),
1599
+ },
1600
+ // The DOMAIN-grain key (a thread/forum-post resolves to its parent channel via domainKeyOf),
1601
+ // NOT the raw thread surfaceKey — the unified receive gate re-checks crossSurfaceWindow with
1602
+ // an EXACT channels.includes match, and a thread id is never a listed channel. Passing the
1603
+ // thread grain here refuses the wake fail-closed AFTER native was suppressed = orphaned
1604
+ // silence (100% broken in forum channels, where every post is a thread). Identical to
1605
+ // surfaceKey for base channels, so their behaviour is byte-unchanged.
1606
+ surfaceKey: domainSurfaceKey,
1607
+ // OPE-466/OPE-475 CARRIAGE, over the TRIGGER *AND* the backscroll siblings via
1608
+ // `input.contextIngestion` — the caller graded them with {@link gradeDiscordContextLines}
1609
+ // (the SAME resolution the trigger uses) before rendering `input.context`, so the join reads
1610
+ // the SAME facts the render was built from.
1611
+ //
1612
+ // 🔴 DISCORD'S AUTHOR LEG COMES FROM OUR OWN CONFIG (OPE-516). Discord has no organisation
1613
+ // concept, so `discordTrust` can NEVER positively refute an author — `member.pending` is the
1614
+ // one platform signal and it is a SCREENING verdict, not a membership one. So the author leg
1615
+ // is the AUTHOR-TRUST REGISTER (identity/author-trust.ts): on a guild the operator has armed,
1616
+ // an author they did not declare grades `external`. On an UNARMED guild it contributes
1617
+ // nothing, and the room declaration remains the whole statement, exactly as under OPE-489.
1618
+ auth: stampNonMemberChatAuth(input.auth, [{ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }, ...input.contextIngestion], { surfaceKey: domainSurfaceKey }),
1619
+ });
1620
+ }
1621
+ catch (err) {
1622
+ // OPE-970: same gap as the DM divert above — this leg's outer catch (below, `dispatchTurn`'s
1623
+ // caller) only logged to the console. Audit it at the domain surface the wake actually targeted.
1624
+ await auditSupervisorFailure({ org: deploymentOrg(), platform: input.partition.platform, workspace: input.partition.workspace, surface: domainSurfaceKey }, "surface.wake_failed", { message: String(err instanceof Error ? err.message : err).slice(0, 500), wakeId, addressing: input.addressing });
1625
+ throw err;
1626
+ }
1627
+ };
1628
+ // A wake failure PROPAGATES (the caller's catch owns it). OPE-174 removed a
1629
+ // terminal-session-death rotate-and-redeliver branch here: it could never fire — a
1630
+ // conversation-mode session PARKS at eve's token bound rather than failing, and a turn-time
1631
+ // error cannot reject the awaited enqueue anyway (core's agent/channels/session-park.ts is the
1632
+ // reachable handler).
1633
+ // 🔴 EVERY WAKE READS THE ROOM SINCE THE SEAT LAST LOOKED (OPE-1695 (b), brought to Discord by
1634
+ // OPE-1726). A FRESH generation renders its carried tail, as it always did; an ONGOING generation
1635
+ // reads what the channel said since its own `lastWakeAt`, through the ONE shared catch-up path
1636
+ // core holds — same bounds, same admission policy, same cap as Slack's. The read is bounded BELOW
1637
+ // by the seat's last wake and ABOVE by the trigger, so a seat quiet for weeks pays for the recent
1638
+ // tail and nothing more. FAIL-SOFT: the shared path never throws, and an unregistered fetch comes
1639
+ // back as the UNAVAILABLE note rather than as a silent empty context.
1640
+ const catchUp = session.fresh
1641
+ ? undefined
1642
+ : await seatCatchUp("discord", {
1643
+ // The CONTAINER is the standing channel (a thread's PARENT when the auth carries one);
1644
+ // the CONVERSATION is the thread, whose own channel id IS its read endpoint on Discord.
1645
+ container: (isThreadChannelType(msg.channelType) ? str(input.auth.attributes.parent_channel_id) : undefined) ?? msg.channelId,
1646
+ ...(isThreadChannelType(msg.channelType) ? { threadRoot: msg.channelId } : {}),
1647
+ sinceMs: seatCatchUpSinceMs(existing?.lastWakeAt, Date.parse(atIso) || Date.now(), DISCORD_RAW_FETCH_REACHBACK_HOURS),
1648
+ untilMs: Date.parse(atIso) || Date.now(),
1649
+ ...(msg.id ? { excludeId: msg.id } : {}),
1650
+ channelKey: domainKeyOf(input.partition),
1651
+ // The caller's OWN vouch verdict — the same fact the trigger's tier was resolved from.
1652
+ surfaceInternal: input.privacyOk,
1653
+ }, {
1654
+ trustFor: backscrollTrustResolver({ surfaceInternal: input.privacyOk, channelKey: domainKeyOf(input.partition) }),
1655
+ // No `admit` (OPE-1857): the shared road resolves the domain's policy from the
1656
+ // `channelKey` the request above already carries — the same key this line would have used.
1657
+ });
1658
+ await doWake(session.token, session.generation, session.fresh ? session.tail : (catchUp?.messages ?? []), catchUp?.unavailable);
1659
+ await store.touchChannelSession(org, grain.registryKey, atIso).catch(() => undefined);
1660
+ // OPE-794 — file this wake on the live generation's tail ring, AFTER it landed, and only the
1661
+ // TRIGGER: never the backscroll rendered beside it. That is what stops a tail inheriting a tail
1662
+ // (core's channels/session-tail.ts). Same registry key the rotation read and the spend counter
1663
+ // use, so all three target one row.
1664
+ await recordWakeTail(org, grain.registryKey, session.token, {
1665
+ kind: "wake",
1666
+ at: atIso,
1667
+ from: "human", // bot authors are dropped at ingress (authForRelayMessage)
1668
+ trust,
1669
+ // THE AUTHOR GRAIN (OPE-1747) — byte-for-byte the facts this route's own `stampNonMemberChatAuth`
1670
+ // join reads for the trigger, resolved through the one core road.
1671
+ ...tailAuthorGrain({ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }),
1672
+ author: msg.author.username ?? msg.author.id,
1673
+ authorId: msg.author.id,
1674
+ id: msg.id || "unknown",
1675
+ platform: "discord",
1676
+ container: input.partition.surfaceKey,
1677
+ text: msg.content,
1678
+ });
1679
+ }
1680
+ /**
1681
+ * Resolve the wake decision (consulting the engagement registry only when needed) and, if it
1682
+ * passes, start/resume the durable session. A mention also ENGAGES the channel so later
1683
+ * non-mention replies keep waking it (reply-without-re-tagging, exactly like Slack). Runs in
1684
+ * the background (handed to waitUntil) so the route acks fast.
1685
+ */
1686
+ export async function dispatchTurn(msg, auth, send,
1687
+ // Injectable so the auto-thread fail-open path is testable without a live Discord call.
1688
+ createThread = createDiscordThread,
1689
+ // Injectable so the thread→parent grain path is testable without a live Discord call.
1690
+ resolveParent = threadParentChannelId,
1691
+ // eve's cross-channel `to` (the route supplies it) — the SINGLE-WINDOW divert primitive.
1692
+ // Optional + additive: absent (older callers/tests) ⇒ the divert block never runs, native path.
1693
+ receive) {
1694
+ // THREAD→PARENT GRAIN (item 22). A message arriving IN an existing thread carries the
1695
+ // thread's own channel id, which never matches a domain listing the parent channel and
1696
+ // never accumulates against the parent's spend cap. Resolve the parent ONCE here (one
1697
+ // cached REST `GET /channels/{id}` — discord-channel-info.ts, shared with the approval
1698
+ // scope labels) and stamp it onto the auth, so everything downstream (partition
1699
+ // domainKey, memory/RBAC/routing hops, the spend gate + hooks) stays sync. BEST-EFFORT:
1700
+ // an unresolvable parent (no token, REST failure) keeps today's thread-grain behaviour
1701
+ // — the turn is never blocked on this. (The auto-thread path below stamps its parent
1702
+ // for free in dispatchSurfaceFor; this handles turns that START in a thread.)
1703
+ if (isThreadChannelType(msg.channelType)) {
1704
+ const parentChannelId = await resolveParent(msg.channelId);
1705
+ if (parentChannelId) {
1706
+ auth = buildDiscordAuth({
1707
+ userId: msg.author.id,
1708
+ guildId: msg.guildId,
1709
+ channelId: msg.channelId,
1710
+ channelType: msg.channelType,
1711
+ parentChannelId,
1712
+ // OPE-671: preserve the ✋ anchor — this REBUILDS auth from scratch, which would otherwise
1713
+ // silently drop the messageId authForRelayMessage already stamped.
1714
+ messageId: msg.id,
1715
+ });
1716
+ }
1717
+ }
1718
+ const partition = resolvePartition(ctxFromAuth(auth));
1719
+ const key = engagementKey(msg);
1720
+ const isDm = isDmChannel(msg.channelType);
1721
+ // A DM or a mention always wakes; only a plain guild message needs the (async) engagement lookup.
1722
+ // ONE engagement read, and it is the ROW rather than a boolean (OPE-418) — the row carries the
1723
+ // durable STOOD-DOWN marker as well as "is this engaged", and both decide the same question.
1724
+ // ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ──────────────────
1725
+ //
1726
+ // POSITION IS LOAD-BEARING: this sits ABOVE the engagement read, the spend gate, every affordance
1727
+ // (👀, typing, auto-thread) and both window diverts. A message the agent may not act on must cost
1728
+ // nothing and show nothing — an affordance on a suppressed message tells the sender the agent is
1729
+ // reading them, which is the opposite of the intended behaviour.
1730
+ //
1731
+ // Both grains are inert under their shipped values (`taint` / `engage`), so this whole block is a
1732
+ // no-op on every deployment that has not opted in.
1733
+ // EXPLICIT ADDRESSING — one fact, read twice: the verdict owes a private notice on it, and the
1734
+ // OPE-885 ack keys on it. A DM is addressing by construction; `mentionsBot` is Discord's own
1735
+ // mention signal, which a NAME-DROP never sets.
1736
+ const addressed = isDm || msg.mentionsBot;
1737
+ const externalVerdict = discordExternalVerdict({
1738
+ policy: externalPolicyForRoom(domainKeyOf(partition)),
1739
+ // The SAME per-message facts the taint author leg reads (access/taint-signal.ts): the resolved
1740
+ // human tier plus the operator's author-trust query. Discord contributes no `refuted` — the
1741
+ // platform has no organisation concept to refute against, which is why the register exists.
1742
+ author: {
1743
+ trust: resolveWakeTrustFromContext(ctxFromAuth(auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: true }),
1744
+ ...authorFacts(msg),
1745
+ },
1746
+ addressed,
1747
+ isDm,
1748
+ roomKey: domainKeyOf(partition),
1749
+ });
1750
+ if (externalVerdict !== "proceed") {
1751
+ // BEHAVIOUR 1 — an ADDRESSED message is owed an answer, so the sender gets a PRIVATE notice and
1752
+ // the agent does nothing else. BEHAVIOUR 2 — an ambient message was owed nothing, so it is
1753
+ // dropped in silence. The ROOM verdict posts its once-ever announcement instead. None wakes a
1754
+ // model turn, and none shows an affordance.
1755
+ // The private notice and the once-ever room announcement both ANSWER the person who addressed
1756
+ // us, and both say more than the ack does — so either suppresses it.
1757
+ let alreadyAnswered = false;
1758
+ if (externalVerdict === "withheld-addressed")
1759
+ alreadyAnswered = await postExternalSenderNotice(msg.author.id).catch(() => false);
1760
+ if (externalVerdict === "silent-room")
1761
+ alreadyAnswered = await announceSilentRoom(partition.surfaceKey, msg.channelId).catch(() => false);
1762
+ // THE MENTION ACK (OPE-885): an explicit @mention or DM that got no private answer gets the one
1763
+ // fixed host line, in the channel it was said in. An AMBIENT or NAME-DROP suppression stays
1764
+ // fully dark — `addressed` is false for both, and this is the only gate.
1765
+ const acked = owesSuppressedMentionAck({ addressed, alreadyAnswered }) &&
1766
+ (await discordCreateMessage(msg.channelId, EXTERNAL_MENTION_ACK).then((id) => id !== null, () => false));
1767
+ await appendAudit({
1768
+ org: deploymentOrg(),
1769
+ platform: "discord",
1770
+ workspace: partition.workspace,
1771
+ surface: partition.surfaceKey,
1772
+ principalId: msg.author.id,
1773
+ action: "discord.external_policy_suppressed",
1774
+ detail: { verdict: externalVerdict, channelId: msg.channelId, ...suppressionRendering(acked) },
1775
+ }).catch(() => undefined);
1776
+ log(`suppressed ${partition.surfaceKey}: externalPolicy verdict "${externalVerdict}"`);
1777
+ return;
1778
+ }
1779
+ const engRow = isDm || msg.mentionsBot ? null : await getThreadEngagement(partition, key);
1780
+ const engaged = isDm || msg.mentionsBot ? true : engRow !== null;
1781
+ // STOOD-DOWN SUPPRESSION (OPE-418), replacing the STOP REGEX that stood here. The regex matched
1782
+ // the word "stop" in a followed surface and DELETED the engagement row; the agent now judges the
1783
+ // ask itself between turns (`stand_down`) and sets a flag instead. A flag, not a delete, is what
1784
+ // makes this gate possible at all: a deleted row is indistinguishable from a surface the agent was
1785
+ // never invited into, so the old mechanism could suppress nothing here. A direct @mention is the
1786
+ // re-invitation and always passes — it clears the marker via markEngaged({participating: true}).
1787
+ if (!isDm && !msg.mentionsBot && engRow?.stopped) {
1788
+ log(`suppressed ${partition.surfaceKey} — stood down here until an explicit @mention`);
1789
+ return;
1790
+ }
1791
+ let nameDrop = false;
1792
+ if (!shouldDispatchDiscord({ mentionsBot: msg.mentionsBot, isEngaged: engaged, isDm })) {
1793
+ // NAME-DROP WAKE (item 25 — Slack parity): an un-followed guild surface whose message
1794
+ // says the bot's display name gets a JUDGED turn (may end NO_REPLY) when the domain
1795
+ // opted in via `nameDropWake` — checked at DOMAIN grain (domainKeyOf: a thread asks
1796
+ // its parent channel's domain), same fail-closed default as Slack. No thread, no 👀,
1797
+ // no engagement: the agent was not invited; the judged preamble owns the restraint.
1798
+ // 🔴 THE TWO HALVES ARE READ SEPARATELY (OPE-1899), because only the split can tell a message
1799
+ // that NAMED the agent from one that named nobody. `nameDropEligible` folds the name match into
1800
+ // one boolean, so a candidate the DOMAIN FLAG refused is the one refusal this surface can state
1801
+ // honestly — and it is exactly the refusal an operator reads back when the road looks dead.
1802
+ const eligible = nameDropEligible({
1803
+ mentionsBot: msg.mentionsBot,
1804
+ isDm,
1805
+ isEngaged: engaged,
1806
+ // An allowlisted e2e driver counts as a user here too (item 29) — its name-drop
1807
+ // scenarios must exercise the same gate a human's would. Everyone else: bot = bot.
1808
+ authorIsBot: msg.author.bot && !isAllowedDriver(msg.guildId, msg.author.id),
1809
+ channelType: msg.channelType,
1810
+ content: msg.content,
1811
+ botName: msg.botName,
1812
+ });
1813
+ nameDrop = eligible && nameDropWakeEnabled(domainKeyOf(partition), getConfig().loadDomains());
1814
+ if (!nameDrop) {
1815
+ // A candidate the flag refused writes the road's SILENT row; a message that named nobody
1816
+ // writes nothing, which is the volume bound channels/name-drop-wake-audit.ts states.
1817
+ if (eligible) {
1818
+ await auditNameDropSilent(nameDropAuditKey(partition, msg), "nameDropWake not enabled for this channel's domain");
1819
+ }
1820
+ log(`drop ${partition.surfaceKey}: non-mention in a channel the agent isn't following`);
1821
+ return;
1822
+ }
1823
+ // A name-drop IN A THREAD engages the thread — Slack's post-name-drop grain (its
1824
+ // reply thread is followed), reached deterministically at dispatch (an engage-on-post
1825
+ // state seed is dropped whenever this send RESUMES an old thread session, e.g. one of
1826
+ // our own auto-threads whose engagement lapsed). A thread is a small, low-traffic
1827
+ // scope and the stop verb above is the undo. BASE-CHANNEL name-drops deliberately do
1828
+ // NOT engage — that would put a judged model turn on every subsequent channel
1829
+ // message (the engaged-channel surprise from live testing). Best-effort.
1830
+ if (isThreadChannelType(msg.channelType)) {
1831
+ await markThreadEngaged(partition, key, new Date().toISOString());
1832
+ }
1833
+ // 🔴 THE POSITIVE ROW (OPE-1899) — the one line at which this road has decided to wake. Discord
1834
+ // declines the wake-arm seam (channels/wake-arm.ts), so `via` is always the built-in policy here,
1835
+ // and this route opens the native session rather than handing off to a standing seat.
1836
+ await auditNameDropWoke(nameDropAuditKey(partition, msg), "name-dropped in an enabled guild surface — judged wake", {
1837
+ road: "native",
1838
+ via: "built-in",
1839
+ });
1840
+ log(`name-drop wake on ${partition.surfaceKey} ("${msg.botName}")`);
1841
+ }
1842
+ // SPEND CAP (pre-flight — the ONE shared gate, spend/ingress-gate.ts, the same function every
1843
+ // other surface and the cross-surface divert call): a scope over its monthly budget DECLINES the
1844
+ // turn with a VISIBLE refusal, never a silent drop, before any affordance (reaction/thread/
1845
+ // typing) is shown. The CHECK fails OPEN (a broken store must never take a team's agent off the
1846
+ // air); a definite OVER fails CLOSED — unless the sender is an admin over this scope, in which
1847
+ // case the turn proceeds past the cap with a visible, hourly-throttled ⚠️ plus an audit row (sudo
1848
+ // semantics: the guardrail binds the team, never locks out the budget owner; spend/bypass.ts).
1849
+ // Discord is the surface whose admin evidence has TWO legs — the config roster and the
1850
+ // REST-verified guild administrator, reached through the registered native-admin check, which is
1851
+ // the reference implementation for that seam. DMs are NOT exempt (OPE-267 deleted the exemption);
1852
+ // the gate picks the DM refusal variant itself, because "DM me to propose an override" is a loop
1853
+ // with no exit when the refusal IS the DM.
1854
+ //
1855
+ // POSITION IS THE LOAD-BEARING PART, and Discord's has always been right: this gate runs BEFORE
1856
+ // the single-window diversion below, so a Discord wake is decided here and never reaches the
1857
+ // window ungated. Teams/GitHub/Linear were the opposite — their window leg returned BEFORE their
1858
+ // native gate, which is the hole the divert-side gate (channels/unified.ts, door "window")
1859
+ // exists to close. Both of Discord's doors now render the SAME refusal copy; while this block was
1860
+ // hand-rolled they disagreed on one clause, so which door declined you decided which sentence you
1861
+ // read.
1862
+ //
1863
+ // WHY THE HAND-ROLLED COPY IS GONE (OPE-302). It was kept deliberately for a while — byte-pinned
1864
+ // by the scenario goldens, so migrating was a behaviour change with a re-record rather than a
1865
+ // refactor. Two things came with the migration: the `spend.gate` audit row Discord alone never
1866
+ // wrote (the divergence `6687d86f` recorded), and the loss of the two `log()` lines — deliberate,
1867
+ // since a webhook function's console output never reaches `vercel logs`, which is exactly why the
1868
+ // decision rides the audit trail instead.
1869
+ const spendDoor = msg.mentionsBot || isDm ? "mention" : "engagement";
1870
+ if ((await ingressSpendGate({
1871
+ // The wake's OWN auth — including the thread→parent stamp resolved above — so the gate's
1872
+ // internal domainKeyOf lands on the SPEND grain (a thread caps against its parent channel),
1873
+ // which is the grain the cap gates and the key an override proposal must target. Freezing a
1874
+ // thread's ephemeral key here was the live 2026-07-06 bug that grain exists to fix.
1875
+ bindable: ctxFromAuth(auth),
1876
+ // Discord's ONE ingress serves both of Slack's doors, so name the one this wake used: an
1877
+ // @mention or a DM is the mention door; an engaged follow-up or a name-drop is the
1878
+ // engagement door. That distinction is the whole point of the field — a live probe has to be
1879
+ // able to tell which path refused.
1880
+ door: spendDoor,
1881
+ // The author id IS the attributed principal (discord-identity.ts stamps
1882
+ // `principalId: userId`), so the person this turn's spend would be recorded under is the
1883
+ // only person whose admin standing can buy a bypass of their own bill.
1884
+ principalId: msg.author.id,
1885
+ post: async (text) => {
1886
+ await discordCreateMessage(msg.channelId, text);
1887
+ },
1888
+ })) === "declined") {
1889
+ return; // drop — no model turn
1890
+ }
1891
+ // THE DM WINDOW DIVERT (OPE-356) — a DM lands in its OWN standing window, NOT in any shared one.
1892
+ //
1893
+ // POSITION IS LOAD-BEARING AND IT IS HERE FOR TWO REASONS. It sits AFTER the spend gate, so a DM
1894
+ // is decided at Discord's own door and never reaches a window ungated (the property the comment
1895
+ // above calls Discord's, kept). And it sits BEFORE the single-window block, which is not a race:
1896
+ // that block's privacy floor refuses a guildless surface UNCONDITIONALLY, so a DM could never
1897
+ // enter a shared window either way — the ordering just says out loud which window a DM belongs
1898
+ // to, rather than leaving it to a `return false` two blocks down.
1899
+ //
1900
+ // BOTH DM SHAPES, one predicate ({@link isDmChannel}: type 1 and type 3). Slack first-classed 1:1
1901
+ // `im` at stage 2 and its group DM (`mpim`) separately at stage 4 (OPE-355), because its
1902
+ // DM-partner trust lookup is a 1:1 concept the mpim leg had to route around; Discord has no such
1903
+ // lookup (discordTrust reads `member.pending`, which a DM has no
1904
+ // member object for), and `rootDmGrainAttrs` already treats both as one standing grain. Splitting
1905
+ // them here would invent a Discord-internal asymmetry that nothing in the fence needs — each is
1906
+ // its own surface, so each gets its own surface-keyed window and its own byte-exact fence.
1907
+ //
1908
+ // Fail-closed to the NATIVE path when there is no `receive` at all (an older caller/test) ⇒
1909
+ // byte-identical to the pre-OPE-356 per-channel DM session. The deployment-level `OE_DM_SEAT_ROTATION`
1910
+ // hatch that used to sit beside it is gone (OPE-368): it was the last supported way to leave a DM on
1911
+ // an un-wakeable native token, which is exactly the seat class the retired delegation anchor served.
1912
+ if (isDm && receive) {
1913
+ // The 👀 "seen" ack SURVIVES the re-seat — it is the DM's only affordance and a one-shot REST
1914
+ // PUT with no session-coupled cleanup (a non-threaded 👀 STANDS as "seen"; only the auto-thread
1915
+ // case is swap-tracked, and a DM never auto-threads). Typing deliberately does NOT: its
1916
+ // re-triggers live on THIS channel's event handlers, which a diverted turn never reaches, so a
1917
+ // single ~10s burst against a minute-long turn would read as "typing… then nothing" — the
1918
+ // looks-like-a-crash tell the judged-wake typing gate exists to avoid.
1919
+ if (statusUxEnabled())
1920
+ await setDiscordReaction(msg.channelId, msg.id, EYES_EMOJI, "PUT");
1921
+ await divertDiscordDmToWindow({ msg, auth, partition, receive });
1922
+ await appendAudit({
1923
+ org: deploymentOrg(),
1924
+ platform: "discord",
1925
+ // A DM has no guild; the binding keys its workspace to the caller's own user id (bindDiscord),
1926
+ // which is what `partition` already carries — no fabricated guild id.
1927
+ workspace: partition.workspace,
1928
+ surface: partition.surfaceKey,
1929
+ principalId: msg.author.id,
1930
+ action: "discord.dispatched",
1931
+ detail: { addressing: "direct-mention", dmWindow: true, channelId: msg.channelId },
1932
+ }).catch(() => undefined);
1933
+ log(`dm-window divert ${partition.surfaceKey}`);
1934
+ return;
1935
+ }
1936
+ // SINGLE-WINDOW DIVERSION (the cross-surface shared-context window — see the section header
1937
+ // above). When this surface's domain opted into crossSurfaceWindow AND the Discord privacy
1938
+ // floor passes (discordSingleWindowPrivacyOk — an operator-VOUCHED guild surface; a DM never
1939
+ // passes), the whole wake diverts into the synthetic unified channel: an @mention as an
1940
+ // ADDRESSED `direct-mention` wake — the mention policy's suppressNative IS this early return,
1941
+ // so none of the native affordances below (auto-thread, 👀, typing, per-channel session) run —
1942
+ // and engaged/name-drop wakes as judged watch wakes. The mention still ENGAGES the surface
1943
+ // first (exactly as the native path does) so plain follow-ups keep waking — and keep
1944
+ // diverting. Floor not passed / flags off / no receive ⇒ fall through: byte-identical native
1945
+ // behaviour (fail-closed: an un-vouched surface never joins the shared window).
1946
+ const windowGrain = discordGrainFor(domainKeyOf(partition));
1947
+ if (grainUsesUnifiedWindow(windowGrain) && windowGrain.domainName !== undefined && receive) {
1948
+ const privacyOk = discordSingleWindowPrivacyOk({
1949
+ guildId: msg.guildId,
1950
+ workspaceKey: partition.workspaceKey,
1951
+ surfaceKey: partition.surfaceKey,
1952
+ domainKey: domainKeyOf(partition),
1953
+ });
1954
+ // The ONE shared mention decision (unified-window.ts seam via the thin Discord shim):
1955
+ // addressed `direct-mention` ⟺ the native dispatch stands down for this mention.
1956
+ const mentionPolicy = discordSingleWindowMentionPolicy(true, msg.mentionsBot, privacyOk);
1957
+ if (privacyOk) {
1958
+ if (msg.mentionsBot) {
1959
+ // A mention is an invitation — participating engagement on THIS surface (the base
1960
+ // channel/thread the mention landed in; no auto-thread on the window path), mirroring
1961
+ // the native path's landmine fix so follow-ups wake without re-tagging.
1962
+ await markThreadEngaged(partition, key, new Date().toISOString(), { participating: true });
1963
+ }
1964
+ // Backscroll: same condition as the native path (mention/name-drop hydration; an engaged
1965
+ // follow-up's conversation already lives in the shared session). Fail-soft [].
1966
+ // OPE-475: fetch the RAW lines (not the pre-rendered-string wrapper) so they can be graded
1967
+ // with the SAME per-line resolution the native path uses ({@link gradeDiscordContextLines})
1968
+ // before rendering — the render then carries the reference-only note for any demoted line,
1969
+ // and `contextIngestion` carries the SAME facts to the join, which never sees the lines
1970
+ // themselves on this leg (only the pre-rendered `context` strings reach `divertToUnifiedWindow`).
1971
+ const windowLines = nameDrop || msg.mentionsBot
1972
+ ? await fetchDiscordBackscrollLines({
1973
+ channelId: msg.channelId,
1974
+ triggerMessageId: msg.id,
1975
+ isThread: isThreadChannelType(msg.channelType),
1976
+ botId: msg.botId,
1977
+ }).catch(() => undefined)
1978
+ : undefined;
1979
+ const gradedWindowLines = windowLines ? await gradeDiscordContextLines(windowLines.lines, ctxFromAuth(auth), msg.guildId) : undefined;
1980
+ // THE DOMAIN'S ADMISSION POLICY, THE RENDER AND THE JOIN FACTS, over ONE admitted set
1981
+ // ({@link discordSeatContextForWindow} — OPE-1800). This is the SEAT road, so the policy runs
1982
+ // here and nowhere else on Discord; the native dispatch below keeps its whole hydration.
1983
+ const seatContext = discordSeatContextForWindow(gradedWindowLines, windowLines?.source, windowGrain, domainKeyOf(partition), isThreadChannelType(msg.channelType));
1984
+ const addressing = mentionPolicy.addressing ?? (nameDrop && msg.botName ? "name-drop" : "channel-watch");
1985
+ await divertDiscordWakeToUnifiedWindow({
1986
+ msg,
1987
+ auth,
1988
+ partition,
1989
+ grain: windowGrain,
1990
+ addressing,
1991
+ privacyOk,
1992
+ // The ENROLMENT fact, off the row this dispatch ALREADY read (OPE-1740). The narrow reading
1993
+ // every other surface states: an @mention (or an earned reply) invited the agent here, and
1994
+ // the invitation still stands. A warm name-drop latch (`participating: false`) is not an
1995
+ // invitation, and a stopped row has already been left.
1996
+ threadEngaged: engRow?.participating === true && engRow.stopped !== true,
1997
+ receive,
1998
+ contextLines: seatContext.lines,
1999
+ contextSource: seatContext.source,
2000
+ contextIngestion: seatContext.ingestion,
2001
+ });
2002
+ await appendAudit({
2003
+ org: deploymentOrg(),
2004
+ platform: "discord",
2005
+ workspace: msg.guildId ?? "unknown",
2006
+ surface: partition.surfaceKey,
2007
+ principalId: msg.author.id,
2008
+ action: "discord.dispatched",
2009
+ detail: { addressing, singleWindow: true, channelId: msg.channelId },
2010
+ }).catch(() => undefined);
2011
+ log(`single-window divert ${partition.surfaceKey} (${addressing})`);
2012
+ return;
2013
+ }
2014
+ }
2015
+ // AUTO-THREAD. A mention landing in a base guild text/announcement channel opens a dedicated
2016
+ // thread and the WHOLE turn re-keys onto it (see dispatchSurfaceFor). This stays inside the
2017
+ // background dispatch (waitUntil) — one extra REST round-trip, invisible to the fast route
2018
+ // ack. Fail-OPEN: a null from createThread (missing permission, rate limit, API error) keeps
2019
+ // the base channel so the turn still runs inline — threading never gates delivery. Only a
2020
+ // mention threads; a non-mention engaged wake is ALREADY in its conversation surface (a base
2021
+ // channel it's following, or a thread of type 10/11/12 that shouldAutoThread excludes).
2022
+ const statusUx = statusUxEnabled();
2023
+ let threadId = null;
2024
+ if (msg.mentionsBot && shouldAutoThread(msg.channelType, autoThreadEnabled())) {
2025
+ threadId = await createThread(msg.channelId, msg.id, deriveThreadName(msg.content));
2026
+ if (!threadId) {
2027
+ log(`auto-thread failed for ${partition.surfaceKey} — falling back to an inline reply in the base channel`);
2028
+ }
2029
+ }
2030
+ // STATUS UX: the 👀 "seen" ack — on DIRECT addresses (mention or DM), the turns with a
2031
+ // guaranteed reply. On the auto-threaded mention it is swap-tracked (→ ✅ when the answer
2032
+ // lands in the thread — the one case where the reply is away from the message AND a fresh
2033
+ // session guarantees the seeded swap coords are never stale). Everywhere else it simply
2034
+ // STANDS as "seen" — the answer arrives right underneath, so there's nothing to signal by
2035
+ // removing it, and an untracked ack is immune to the resumed-session staleness class.
2036
+ // Engaged non-mention wakes get NO reaction: the agent may judge silence, and eyes-ing every
2037
+ // message in a followed channel reads as surveillance. Best-effort: setDiscordReaction
2038
+ // swallows every failure — never blocks or drops the turn.
2039
+ if (statusUx && (msg.mentionsBot || isDm)) {
2040
+ await setDiscordReaction(msg.channelId, msg.id, EYES_EMOJI, "PUT");
2041
+ }
2042
+ // Resolve the surface the turn actually runs on: the thread (re-keyed) when one was created,
2043
+ // else the base channel. Everything downstream (identity, continuation, reply state,
2044
+ // engagement) reads from this single descriptor so the base/thread split lives in one place.
2045
+ const surface = dispatchSurfaceFor(msg, auth, threadId);
2046
+ // When we re-keyed to a thread, engagement must be stored under the THREAD's surfaceKey (its
2047
+ // own silo), not the base channel's — so re-resolve the partition from the thread auth.
2048
+ const dispatchPartition = threadId ? resolvePartition(ctxFromAuth(surface.auth)) : partition;
2049
+ // Engage on mention so subsequent plain replies in this SURFACE also wake (best-effort).
2050
+ // On auto-thread this engages the THREAD (where the conversation lives), NOT the base channel
2051
+ // — a later base-channel message needs its own mention (→ its own thread), per the plan.
2052
+ //
2053
+ // participating:true — THE PARTICIPATING-FLAG LANDMINE FIX (mirrors slack.ts onInbound): a
2054
+ // mention is an INVITATION, so the surface must be an invited PARTICIPANT, not a merely-warm
2055
+ // watcher. Without the flag the mention left a `participating:false` row, which the new warm
2056
+ // auto-disengage below would then shed after a run of silent turns — @mentioning the bot must
2057
+ // never make it sheddable. participating:true also resets the silent streak and exempts the
2058
+ // surface from the warm shed (sticky until an explicit stop).
2059
+ if (msg.mentionsBot) {
2060
+ await markThreadEngaged(dispatchPartition, surface.engagementKey, new Date().toISOString(), { participating: true });
2061
+ }
2062
+ // Assemble the state SEEDED into send (applied only when this send STARTS a session): the
2063
+ // reply surface follows the re-key (surface.state.channelId); the 👀→✅ reaction target is
2064
+ // included ONLY for the auto-threaded case (see the reaction comment above — the one case
2065
+ // where the seed is guaranteed to reach a fresh session).
2066
+ const turnState = buildGatewayState({
2067
+ replyChannelId: surface.state.channelId,
2068
+ // Seed the guild so the completion handler can rebuild the engagement partition (promote on
2069
+ // reply / shed on silence). msg.guildId is the same for the base channel and any thread it
2070
+ // spawns (a thread never leaves its guild); absent for DMs → no engagement bookkeeping.
2071
+ guildId: msg.guildId,
2072
+ original: statusUx && threadId ? { channelId: msg.channelId, messageId: msg.id } : undefined,
2073
+ });
2074
+ // Working signal: fire the native typing indicator on the reply surface right away (covers
2075
+ // the gap until the turn's own events take over re-triggering it). Best-effort. JUDGED
2076
+ // wakes (engaged / name-drop — anything not directly addressed) may end NO_REPLY, so for
2077
+ // those the gate holds ALL typing back until the streamed reply text proves the model is
2078
+ // actually answering — a judged silence then shows NOTHING instead of "typing… then
2079
+ // nothing" (the looks-like-a-crash tell from live testing).
2080
+ if (statusUx) {
2081
+ if (msg.mentionsBot || isDm) {
2082
+ openTypingGate(surface.state.channelId);
2083
+ await triggerDiscordTyping(surface.state.channelId);
2084
+ }
2085
+ else {
2086
+ closeTypingGate(surface.state.channelId);
2087
+ }
2088
+ }
2089
+ // Durable resume: the continuation token keys the session on the (base or thread) channel id;
2090
+ // a thread is its own channel id so threads resume independently. The reply posts back into
2091
+ // turnState.channelId via the message.completed handler. The framework prepends the
2092
+ // channel's name to the token.
2093
+ // WAKE RENDER — the ONE shared renderer (normalized-wake.ts renderWake), NOT a Discord-local
2094
+ // dialect. Classify the addressing the native path already computed: a name-drop is `name-drop`
2095
+ // (highest bar); a mention/DM is `direct` (the conversation driver — raw body pass-through, no
2096
+ // preamble/envelope, byte-identical to the old renderTurn(true)); an engaged non-mention is
2097
+ // `thread-follow` (judged). The judged paths (thread-follow / name-drop) now render the FULL wake
2098
+ // envelope + the canonical Slack-parity preamble instead of the old drifted prose stub — the
2099
+ // convergence this migration exists to make.
2100
+ const isThread = isThreadChannelType(msg.channelType);
2101
+ const addressing = nameDrop && msg.botName ? "name-drop" : msg.mentionsBot || isDm ? "direct" : "thread-follow";
2102
+ // PER-WAKE TRUST (identity/wake-trust.ts — audit-wedge F3), off the SAME auth the session binds
2103
+ // with (the divert path resolves it identically). The trigger's author IS the message sender. A
2104
+ // `direct` body passes through verbatim (trust unused in that render); a judged body renders it as
2105
+ // the envelope's `<message trust=…>` attribute. `principalFallback` on `direct` mirrors the divert
2106
+ // builder's `direct ? "principal" : "peer"` default.
2107
+ const trust = resolveWakeTrustFromContext(ctxFromAuth(surface.auth), { id: msg.author.id, isBot: msg.author.bot }, { principalFallback: addressing === "direct" });
2108
+ // WAKE/MENTION BACKSCROLL (Slack parity — discord-backscroll.ts): a name-drop or an @mention used
2109
+ // to reach the model with ONLY the triggering message. Fetch the surrounding conversation as RAW
2110
+ // ContextLines (W3 — no pre-rendered string flows through the plumbing; renderWake wraps them via
2111
+ // the shared renderContext). We read the SURFACE THE HUMAN IS IN (msg.channelId) — for an
2112
+ // auto-threaded mention that's the base channel where the conversation is, NOT the fresh empty
2113
+ // reply thread. Skipped for DMs (the 1:1 history IS the conversation, carried by the durable
2114
+ // session) and for plain engaged non-mention wakes (a follow-up in a conversation the session
2115
+ // already holds). Fail-soft: undefined on any error → wake with just the message, exactly as before.
2116
+ const backscroll = !isDm && (nameDrop || msg.mentionsBot)
2117
+ ? await fetchDiscordBackscrollLines({
2118
+ channelId: msg.channelId,
2119
+ triggerMessageId: msg.id,
2120
+ isThread,
2121
+ botId: msg.botId,
2122
+ }).catch(() => undefined)
2123
+ : undefined;
2124
+ // OPE-475: regrade the ContextLine backscroll siblings with the SAME per-line resolution the
2125
+ // trigger uses just above, so a guest hydrated behind a member's trigger is graded too.
2126
+ const gradedBackscrollLines = backscroll ? await gradeDiscordContextLines(backscroll.lines, ctxFromAuth(surface.auth), msg.guildId) : undefined;
2127
+ // THE DOMAIN'S ADMISSION POLICY ON THIS ROAD TOO ({@link admitDiscordSeatLines} — OPE-1800), and
2128
+ // the seam decides by the ROOM's grain, never by the road. A wake reaches this native arm either
2129
+ // because the room has no seat at all — then nothing is narrowed — or because its seat STOOD DOWN
2130
+ // (the guild vouch absent, or no `receive`), which access/window-routing.ts audits as
2131
+ // `native-degraded`. Policing both is what keeps ONE admission answer per domain: a runtime vouch
2132
+ // must not be able to widen what a `members-only` room's wake reads.
2133
+ const nativeContextLines = admitDiscordSeatLines(gradedBackscrollLines, windowGrain, domainKeyOf(partition), isThread);
2134
+ const rendered = renderWake(discordNativeWake({
2135
+ channelId: msg.channelId,
2136
+ // Container = the thread's PARENT when known (the domain/spend grain), else the channel; the
2137
+ // conversation is the thread itself. Auth carries the parent when the turn started in a thread
2138
+ // (stamped at the top of dispatchTurn) — mirrors the divert builder's locus mapping.
2139
+ parentChannelId: str(surface.auth.attributes.parent_channel_id),
2140
+ isThread,
2141
+ isDm,
2142
+ addressing,
2143
+ // The ENROLMENT fact, off the row this dispatch ALREADY read (OPE-1740) — the same expression
2144
+ // the unified divert passes, so one conversation cannot answer two ways by grain.
2145
+ threadEngaged: engRow?.participating === true && engRow.stopped !== true,
2146
+ botName: msg.botName,
2147
+ contextLines: nativeContextLines,
2148
+ contextSource: backscroll?.source,
2149
+ trigger: {
2150
+ id: msg.id || "unknown",
2151
+ author: msg.author.username ?? msg.author.id,
2152
+ authorId: msg.author.id,
2153
+ sentAt: new Date().toISOString(),
2154
+ text: msg.content,
2155
+ trust,
2156
+ },
2157
+ }));
2158
+ try {
2159
+ await dispatchNativeWakeWithRetry(() => send(
2160
+ // Backscroll is ADDITIONAL context, never a replacement for the message; omitted when empty so
2161
+ // the payload is identical to today's on the no-history path.
2162
+ rendered.context && rendered.context.length ? { message: rendered.message, context: rendered.context } : rendered.message, {
2163
+ // OPE-466/OPE-475 CARRIAGE, over the TRIGGER *AND* the regraded ContextLine backscroll
2164
+ // siblings. `member.pending` is Discord's only PLATFORM refutation and it is a screening
2165
+ // verdict, and it rides the trigger's own relay payload only — {@link gradeDiscordContextLines}
2166
+ // documents why it is unreachable for a backscroll sibling (Discord's own message-list REST
2167
+ // shape carries no `member` object). The OPERATOR's refutation (`authorTrust`, OPE-516) IS
2168
+ // reachable per sibling, so it is what closes the gap for a backscroll-hydrated guest; it
2169
+ // yields `{}` on a DM, where there is no guild to arm.
2170
+ auth: stampNonMemberChatAuth(surface.auth, [{ trust, refuted: msg.memberPending === true, ...authorFacts(msg) }, ...(nativeContextLines ?? []).map(contextLineIngestion)], isDm ? { dm: true } : { surfaceKey: domainKeyOf(dispatchPartition) }),
2171
+ continuationToken: surface.continuationToken,
2172
+ state: turnState,
2173
+ }), { platform: "discord", workspace: dispatchPartition.workspace, surfaceKey: dispatchPartition.surfaceKey });
2174
+ }
2175
+ catch (err) {
2176
+ // OPE-971: this native guild dispatch (a plain per-channel/per-thread continuation token, NOT
2177
+ // an ambient-supervisor `channel_session` row) has no rotatable registry key — it is the same
2178
+ // "thread seat, native session" shape stranded-seat.ts already documents as unhealable. This
2179
+ // call turns a completely SILENT failure into an AUDITED one (`session.pruned_deployment_strand`,
2180
+ // remedy "none") without inventing a rotation that cannot exist. No registryKey override: the
2181
+ // token is passed as-is and correctly resolves to `not-a-window`.
2182
+ await healPrunedDeploymentStrand({
2183
+ token: surface.continuationToken,
2184
+ surfaceKey: dispatchPartition.surfaceKey,
2185
+ platform: "discord",
2186
+ atIso: new Date().toISOString(),
2187
+ err,
2188
+ });
2189
+ throw err;
2190
+ }
2191
+ log(`dispatched ${dispatchPartition.surfaceKey} (${isDm ? "dm" : msg.mentionsBot ? "mention" : nameDrop ? "name-drop" : "engaged"}` +
2192
+ `${threadId ? ", auto-threaded+👀" : ""})`);
2193
+ }
2194
+ // --- engagement bookkeeping on completion (Slack parity) --------------------
2195
+ // The completion handler runs with only the LIVE session state (no auth), so — exactly like
2196
+ // slack.ts rebuilds its partition from teamId+channelId in the state — we rebuild Discord's
2197
+ // engagement partition from the seeded guild + reply channel id. The engagement KEY is the reply
2198
+ // channel id itself (Discord engagement is per-channel; a thread is its own channel id), matching
2199
+ // how dispatchTurn keys it. Returns null for a DM (no guild → no engagement) or a pre-seed
2200
+ // session — both then skip engagement bookkeeping, which is exactly right.
2201
+ export function engagementCoordsFor(state) {
2202
+ if (!state.guildId || !state.channelId)
2203
+ return null;
2204
+ // userId is irrelevant to the surfaceKey (guild = workspace, channel = surface); pass "" so the
2205
+ // rebuilt partition names discord/<guild>/<channel> exactly as the dispatch partition did.
2206
+ const p = resolvePartition(ctxFromAuth(buildDiscordAuth({ userId: "", guildId: state.guildId, channelId: state.channelId })));
2207
+ return { p, key: state.channelId };
2208
+ }
2209
+ // The audit partition for a terminal decision — same rebuild as engagementCoordsFor but
2210
+ // WITHOUT the guild requirement (a DM turn's silence/reply must still be auditable; its
2211
+ // workspace keys to the binding's DM fallback). Null only when the state names no channel
2212
+ // (pre-seed session) or the rebuild fails — the caller then skips the row (fail-soft;
2213
+ // auditing must never fail or post into the turn).
2214
+ function auditPartitionForGatewayState(state) {
2215
+ if (!state.channelId)
2216
+ return null;
2217
+ try {
2218
+ return resolvePartition(ctxFromAuth(buildDiscordAuth({ userId: "", guildId: state.guildId, channelId: state.channelId })));
2219
+ }
2220
+ catch {
2221
+ return null;
2222
+ }
2223
+ }
2224
+ /**
2225
+ * Promote the surface to PARTICIPATING on a POSTED reply (incl. a card-only reply — the card IS
2226
+ * the reply), mirroring slack.ts's post branch: a warm (name-drop) surface the agent actually
2227
+ * answered goes sticky, its silent streak resets, and its failures go loud.
2228
+ *
2229
+ * THE DISCORD DIVERGENCE (why this reads-then-promotes instead of blindly engaging): Slack promotes
2230
+ * unconditionally because its engagement grain is the THREAD, so a name-drop reply merely
2231
+ * establishing THREAD engagement is harmless. Discord's BASE-channel engagement grain is the WHOLE
2232
+ * CHANNEL, and a base-channel name-drop DELIBERATELY does not engage (dispatchTurn — else every
2233
+ * subsequent channel message becomes a judged model turn, the "engaged-channel surprise" from live
2234
+ * testing). So a base-channel name-drop reply must NOT create whole-channel engagement. We only
2235
+ * PROMOTE an EXISTING warm record (a thread name-drop engaged at dispatch, or a mention/engaged
2236
+ * surface); no record ⇒ a base-channel name-drop ⇒ leave it un-engaged. Best-effort: engagement
2237
+ * must never fail a delivered reply.
2238
+ */
2239
+ export async function promoteParticipatingOnReply(state) {
2240
+ try {
2241
+ const coords = engagementCoordsFor(state);
2242
+ if (!coords)
2243
+ return; // DM / pre-seed session — nothing to promote
2244
+ const rec = await getThreadEngagement(coords.p, coords.key);
2245
+ if (!rec)
2246
+ return; // no existing engagement (base-channel name-drop) — don't grab the whole channel
2247
+ // 🔴 `byOwnPost` — a POSTED reply is not a re-invitation, so this promotion must never lift a
2248
+ // stand-down (OPE-1669, measured on Slack; the shape is identical here). The rule lives in
2249
+ // channels/engagement.ts and re-reads the row rather than reusing `rec` above, so there is
2250
+ // exactly ONE expression of it and no second copy to drift.
2251
+ await markThreadEngaged(coords.p, coords.key, new Date().toISOString(), { participating: true, byOwnPost: true });
2252
+ }
2253
+ catch (err) {
2254
+ console.warn(`[open-everywhere:discord-gateway] participating-promote failed: ${String(err)}`);
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Warm auto-disengage on a JUDGED-SILENT turn (mirrors slack.ts's silent branch): a WARM
2259
+ * (participating:false) surface that keeps judging NO_REPLY is shed after WARM_SILENT_LIMIT turns,
2260
+ * so a one-off name-drop that never earns a reply doesn't leave the bot quietly following forever.
2261
+ * PARTICIPATING surfaces are EXEMPT — they're sticky, only an explicit stop disengages them — so a
2262
+ * missing record OR participating:true is a no-op. Best-effort; engagement bookkeeping never throws
2263
+ * out of the completion handler.
2264
+ */
2265
+ export async function maybeShedWarmSurface(state) {
2266
+ try {
2267
+ const coords = engagementCoordsFor(state);
2268
+ if (!coords)
2269
+ return;
2270
+ const rec = await getThreadEngagement(coords.p, coords.key);
2271
+ if (rec && rec.participating !== true) {
2272
+ const streak = await bumpThreadSilentStreak(coords.p, coords.key);
2273
+ if (streak >= WARM_SILENT_LIMIT) {
2274
+ await disengageThread(coords.p, coords.key);
2275
+ log(`warm surface shed after ${streak} silent turns — disengaged (${coords.p.surfaceKey})`);
2276
+ }
2277
+ }
2278
+ }
2279
+ catch (err) {
2280
+ console.warn(`[open-everywhere:discord-gateway] warm silent-streak bookkeeping failed: ${String(err)}`);
2281
+ }
2282
+ }
2283
+ export default defineChannel({
2284
+ kindHint: "discord",
2285
+ // 🔴 QUEUE, NEVER STEER (OPE-412, OPE-1732). eve's default is `steer`, and a steer delivery
2286
+ // carrying a message CANCELS the turn in flight — rolling session history back to the last
2287
+ // completed model call while that call's tool side effects (posts, PRs, writes) stay real and
2288
+ // unrecorded. The policy resolves per RECEIVING channel, so the pin on `discordChannel`
2289
+ // (channels/discord.ts) covers NOTHING here: that is the slash-command Interactions mount, and
2290
+ // every Discord MESSAGE session lives on this channel, under the `discord:<threadId>` or
2291
+ // `discord:<channelId>` continuation token this route builds. Live-proven on deploy #197
2292
+ // (2026-09-11, session wrun_41M28D8M300GY5HGRQW63MR9ZA): two turns cancelled by ordinary
2293
+ // follow-up messages, one of them 7.3 s after it had already completed and answered. This channel is a raw `defineChannel`,
2294
+ // so `lint:turn-policy` — which reads the five vendor factories — cannot see this line;
2295
+ // `scripts/discord-gateway-turn-policy-test.ts` is what holds it. See
2296
+ // packages/providers/slack/agent/channels/slack.ts for the full note.
2297
+ turnPolicy: "queue",
2298
+ // Default seed; every session-starting dispatch overrides it with the real target channel id.
2299
+ state: { channelId: "" },
2300
+ // Hand event handlers the reply surface + the LIVE state object. `state` is eve's mutable
2301
+ // write-back reference (auto-snapshotted at step boundaries) — turn.started WRITES this turn's
2302
+ // holding-message id into it and message.completed CONSUMES it, which is what keeps the
2303
+ // affordances per-turn-correct on resumed sessions (send-state is first-turn-only).
2304
+ context: (state) => ({
2305
+ channelId: state.channelId,
2306
+ state,
2307
+ }),
2308
+ routes: [
2309
+ POST("/eve/v1/discord-gateway", async (req, { from, waitUntil, to, attachSession }) => {
2310
+ // eve 0.31 binds the channel-local address at `from(address)` rather than passing it in the
2311
+ // send options, and moved `context` from the payload into the options. {@link
2312
+ // DiscordGatewaySend} is OE's OWN dispatch shape (it long predates 0.31), so the whole
2313
+ // dispatchTurn path below is untouched — the version-shaped difference is this one adapter.
2314
+ const gatewaySend = (message, options) => {
2315
+ const payload = typeof message === "string" ? { message, context: undefined } : message;
2316
+ return from(options.continuationToken).send(payload.message, {
2317
+ auth: options.auth,
2318
+ state: options.state,
2319
+ ...(payload.context && payload.context.length > 0 ? { context: payload.context } : {}),
2320
+ });
2321
+ };
2322
+ const rawBody = await req.text();
2323
+ // 1) Verify the relay HMAC over the RAW body, fail-closed. Reject BEFORE parsing so we
2324
+ // never act on (or even JSON.parse) an unauthenticated payload.
2325
+ const rejection = relayGate(req.headers.get("x-oe-relay-signature"), rawBody);
2326
+ if (rejection) {
2327
+ log(`rejected (${rejection.status}): ${rejection.reason}`);
2328
+ return new Response(rejection.reason, { status: rejection.status });
2329
+ }
2330
+ // 2) Parse defensively. A malformed/unrecognized body is a relay bug, not a normal
2331
+ // no-op — 400 (a 4xx the relay logs + drops, never retries).
2332
+ let json;
2333
+ try {
2334
+ json = JSON.parse(rawBody);
2335
+ }
2336
+ catch {
2337
+ return new Response("invalid json", { status: 400 });
2338
+ }
2339
+ // Relay liveness heartbeat (OPE-775) — classified FIRST, before any wake path (message or
2340
+ // reaction) gets a look at the body. It mints no session, no wake, no reply: one audit row,
2341
+ // written async, ack immediately. See discordHeartbeatAuditRow's doc for what the row proves.
2342
+ const heartbeat = parseRelayHeartbeat(json);
2343
+ if (heartbeat) {
2344
+ waitUntil(appendAudit(discordHeartbeatAuditRow(heartbeat, deploymentOrg())).catch(() => undefined));
2345
+ return new Response("ok", { status: 200 });
2346
+ }
2347
+ // Inbound reaction — a distinct relay payload shape. Handled before the message parse:
2348
+ // ingest async (telemetry always, a durable feedback row on a bot reply), ack immediately.
2349
+ // A reaction never starts a turn — but since OPE-418 a ✋ can STOP one, which is why
2350
+ // `attachSession` (eve's I/O-free cancel door, available on every route handler) is handed
2351
+ // in here: the whole point is that a stop costs no session mint and no model call.
2352
+ const reaction = parseRelayReaction(json);
2353
+ if (reaction) {
2354
+ waitUntil(routeDiscordReaction(reaction, liveDiscordReactionDeps(attachSession)).then((o) => log(`reaction ${reaction.channelId}/${reaction.messageId}: ${o.recorded ? "recorded" : "not recorded"} — ${o.reason}`)));
2355
+ return new Response("ok", { status: 200 });
2356
+ }
2357
+ const msg = parseRelayPayload(json);
2358
+ if (!msg)
2359
+ return new Response("unrecognized relay payload", { status: 400 });
2360
+ // 3) Drop bot authors + empty-content messages as ordinary no-ops (200 — nothing to
2361
+ // retry). buildDiscordAuth returns null for a bot author.
2362
+ const auth = authForRelayMessage(msg);
2363
+ if (!auth) {
2364
+ // OPE-647: a MISPLACED driver id (pinned to a different guild) is a cross-tenant
2365
+ // admission-refusal worth naming in the audit trail — every other bot-drop stays silent
2366
+ // (ordinary noise; see isMisplacedDriver's doc for why the two are told apart).
2367
+ if (msg.author.bot && isMisplacedDriver(msg.guildId, msg.author.id)) {
2368
+ await appendAudit({
2369
+ org: deploymentOrg(),
2370
+ platform: "discord",
2371
+ workspace: msg.guildId ?? "unknown",
2372
+ surface: msg.channelId,
2373
+ principalId: msg.author.id,
2374
+ action: "discord.relay_admission_refused",
2375
+ detail: { reason: "e2e-driver credential is pinned to a different guild; this guild is not vouched for it" },
2376
+ }).catch(() => undefined);
2377
+ }
2378
+ return new Response("ignored (bot author)", { status: 200 });
2379
+ }
2380
+ if (msg.content.trim().length === 0)
2381
+ return new Response("ignored (empty content)", { status: 200 });
2382
+ // 4) Dispatch async — the wake gate (engagement lookup) + turn run in the background,
2383
+ // the reply is posted from message.completed. Ack immediately. `to` arms the
2384
+ // single-window divert (byte-identical inert off the crossSurfaceWindow gates); its
2385
+ // fail-closed refusals surface in the catch instead of an unhandled rejection.
2386
+ waitUntil(dispatchTurn(msg, auth, gatewaySend, undefined, undefined, to).catch((err) => log(`dispatch failed: ${String(err)}`)));
2387
+ return new Response("ok", { status: 200 });
2388
+ }),
2389
+ ],
2390
+ events: {
2391
+ // Working signal: re-trigger the native typing indicator ("<bot> is typing…", ~10s TTL) as
2392
+ // the turn progresses. turn.started covers the turn's opening; each tool-call boundary
2393
+ // (actions.requested / action.result) refreshes it; and the STREAMED text/reasoning chunks
2394
+ // (*.appended, throttled — they can fire many times a second) keep it alive through the
2395
+ // final answer's generation, so typing runs right up to the reply landing instead of
2396
+ // lapsing ~10s before it. Gaps during a single long silent tool run remain — same as a
2397
+ // human pausing typing. All best-effort; typing must never delay or drop a turn.
2398
+ //
2399
+ // Every re-trigger ALSO respects the judged-wake typing gate (closeTypingGate at
2400
+ // dispatch): a judged turn shows no typing at all until its streamed message text
2401
+ // deviates from the NO_REPLY sentinel (the message.appended handler feeds the gate) —
2402
+ // so a judged silence is silent from the very first second.
2403
+ "turn.started": async (_data, channel, ctx) => {
2404
+ if (statusUxEnabled() && typingGateOpen(channel.channelId))
2405
+ await triggerDiscordTyping(channel.channelId);
2406
+ // OPE-645: REPLY PRINCIPAL snapshot — snapshotted at turn.started for the
2407
+ // same reason: message.completed has no live auth ctx, so the canonical row it writes cannot
2408
+ // derive WHO from ctx at that point. Since OPE-2006 that row is the DROP row, never a
2409
+ // `reply.post`. authPrincipalId(ctx) is the exact read bindFrom(ctx).principalId falls back to
2410
+ // for an ordinary human turn (identity/binding.ts) — snapshotting it here means the drop row
2411
+ // names the same person the turn's egress.policy rows already do.
2412
+ channel.state.oeReplyPrincipalId = authPrincipalId(ctx);
2413
+ },
2414
+ // Tool-boundary re-triggers ALSO peek the card signal: action.result fires immediately
2415
+ // after the card-posting tool returns — an unconditional re-trigger there is exactly the
2416
+ // "typing for ~10s after the card" residue. (Typing can only ever be triggered, never
2417
+ // cancelled — Discord's indicator is a ~10s TTL — so going quiet at the right moment is
2418
+ // the ONLY lever; the last legitimate trigger's tail is unavoidable.)
2419
+ "actions.requested": async (_data, channel) => {
2420
+ if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn())
2421
+ await triggerDiscordTyping(channel.channelId);
2422
+ },
2423
+ "action.result": async (_data, channel) => {
2424
+ if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn())
2425
+ await triggerDiscordTyping(channel.channelId);
2426
+ },
2427
+ // The *.appended re-triggers PEEK the card signal (never consume — message.completed
2428
+ // owns the read-and-reset): once an approval card has posted this turn, the model's
2429
+ // remaining narration is going to be SUPPRESSED, so keeping "typing…" alive for it
2430
+ // would read as "typing… then nothing". The card is the reply; go quiet.
2431
+ "message.appended": async (data, channel) => {
2432
+ if (!statusUxEnabled())
2433
+ return;
2434
+ const soFar = data?.messageSoFar ?? "";
2435
+ if (!typingGateFeed(channel.channelId, soFar))
2436
+ return;
2437
+ if (!peekCardPostedThisTurn() && typingRefreshDue(channel.channelId))
2438
+ await triggerDiscordTyping(channel.channelId);
2439
+ },
2440
+ "reasoning.appended": async (_data, channel) => {
2441
+ if (statusUxEnabled() && typingGateOpen(channel.channelId) && !peekCardPostedThisTurn() && typingRefreshDue(channel.channelId))
2442
+ await triggerDiscordTyping(channel.channelId);
2443
+ },
2444
+ // A failed turn must NEVER be silent: without this handler a runtime failure left the
2445
+ // human staring at 👀 + a dead thread with no reply and no clue (live 2026-07-06 —
2446
+ // silence is what made a workflow-side failure invisible). Mirrors eve's own channel
2447
+ // default: post a short honest error to the reply surface. Best-effort, never throws.
2448
+ "turn.failed": async (data, channel) => {
2449
+ openTypingGate(channel.channelId); // turn over — never leak a closed gate into the next
2450
+ const detail = data?.error?.message;
2451
+ log(`turn failed (channel ${channel.channelId || "?"})${detail ? `: ${detail}` : ""}`);
2452
+ await postDiscordReply(channel.channelId, "⚠️ I hit an error while handling that and couldn't finish. Please try again — and if it keeps failing, check the deployment logs.");
2453
+ },
2454
+ // Deliver the turn's final reply back to Discord AND reconcile the 👀→✅ swap (auto-threaded
2455
+ // mentions only). finalMessageAction (the shared, unit-tested decision slack-events.ts owns)
2456
+ // stays the SOURCE OF TRUTH for whether there's a reply — a watch routine's NO_REPLY sentinel
2457
+ // is SWALLOWED, and tool-only / empty finals post nothing — exactly as
2458
+ // channels/discord.ts does. planCompletion turns that decision (+ whether this turn seeded swap
2459
+ // coords, read consume-once from the live state) into the concrete ops; the handler is a thin
2460
+ // executor. The reply is always a FRESH post — never an edit, so no "(edited)" tag. Every
2461
+ // affordance op is best-effort/fail-open: the reply lands even if a reaction call fails.
2462
+ "message.completed": async (data, channel, ctx) => {
2463
+ const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
2464
+ const rawAction = finalMessageAction(data.finishReason, data.message);
2465
+ // An interim tool-call step is NOT the final reply — touch nothing (typing re-triggers
2466
+ // via the action events above; the swap coords stay for the real completion).
2467
+ if (rawAction.kind === "buffer-tool-status")
2468
+ return;
2469
+ // Turn is over — clear the judged-wake typing gate so it can't leak into the next turn
2470
+ // on this channel (a silent judged turn leaves its gate closed by design).
2471
+ openTypingGate(channel.channelId);
2472
+ // Consume-once: read this turn's swap coords and CLEAR them, so a resumed later turn can
2473
+ // never react on THIS turn's message (the staleness class an earlier design hit).
2474
+ const status = takeTurnStatus(channel.state);
2475
+ // Card-signal (approvals/card-signal.ts, read-and-reset): a propose tool posted an
2476
+ // approval card this turn → the card IS the reply; suppress the model's narration
2477
+ // exactly as channels/slack.ts does. Consumed only on FINAL actions (the buffer-tool
2478
+ // branch returned above), so an interim completion can't eat the signal early.
2479
+ // repliedThisTurn (reply-signal.ts, same read-and-reset contract as channels/
2480
+ // discord.ts): a mid-turn reply-tool post means an empty terminal is an ANSWERED
2481
+ // turn, not a silence — consumed here so it never bleeds into the next turn either.
2482
+ // TURN-ID KEYED (OPE-805): only THIS turn's own mark answers true.
2483
+ // 🔴 READ ONCE (OPE-1639). The turn id keys every per-turn signal this handler consumes. Since
2484
+ // OPE-2006 there is no terminal reply audit below to key as well: the closing prose is dropped
2485
+ // and audited, so one read serves the silence decision and the signal consumes alike.
2486
+ const turnId = ctx?.session?.turn?.id;
2487
+ const repliedThisTurn = consumeRepliedThisTurn(turnId);
2488
+ const cardPostedThisTurn = consumeCardPostedThisTurn();
2489
+ // OPE-890: read-and-reset the no_reply TOOL signal (no-reply-signal.ts), same contract as
2490
+ // Slack/Teams' finalMessageActionForTurn — needed here ONLY to tell the two silence routes
2491
+ // apart below. Discord's OWN silence decision never needed this (isNoReply(message) alone
2492
+ // decides it, because the tool's own note instructs the model to also emit the sentinel
2493
+ // text — see tools/no_reply.ts's END_TURN_WITH_SENTINEL_INSTRUCTION), so consuming it here
2494
+ // changes nothing about what gets posted or suppressed.
2495
+ const noReplyToolCalledThisTurn = consumeNoReplyThisTurn() !== null;
2496
+ // OPE-1647 — THE STAND-DOWN LATCH, and this handler needs its OWN copy of the read. Slack,
2497
+ // Teams and the Discord INTERACTIONS channel take it inside `finalMessageActionForTurn`; this
2498
+ // one calls the pure `finalMessageAction` instead, because it consumes the `no_reply` signal
2499
+ // itself on the line above to tell the two silence routes apart. Swapping in the ForTurn
2500
+ // variant would consume that signal first and break the split, so the latch is read here, on
2501
+ // the same read-and-reset contract, and turned into the silent verdict below.
2502
+ // 🔴 THIS IS THE GUILD PATH. Every guild wake completes here rather than in channels/
2503
+ // discord.ts, so without this read a stood-down Discord thread reproduces deploy #187 exactly:
2504
+ // the acknowledgement posts, the latch is set, nothing reads it, and the trailing prose follows
2505
+ // it into the conversation the person asked the agent to leave.
2506
+ const stoodDownSilentThisTurn = consumeStandDownSilenceThisTurn(turnId);
2507
+ const action = stoodDownSilentThisTurn && rawAction.kind === "post" ? { kind: "silent", reason: STAND_DOWN_SILENCE_REASON } : rawAction;
2508
+ const plan = planCompletion({
2509
+ action,
2510
+ reactionActive: Boolean(status.original),
2511
+ cardPostedThisTurn,
2512
+ });
2513
+ // 1) Reaction — remove the 👀 from the ORIGINAL mention in its base channel (not the
2514
+ // reply surface, which is the thread). Absence = done; the bot never plants a ✅
2515
+ // (reserved for humans — the Slack-established convention).
2516
+ if (plan.reaction === "clear" && status.original) {
2517
+ await setDiscordReaction(status.original.channelId, status.original.messageId, EYES_EMOJI, "DELETE");
2518
+ }
2519
+ // 2) Deliver the reply into the reply surface (channel.channelId) as a fresh post.
2520
+ // AUDIT the posted reply (issue-surface parity): a discord.replied row records what was
2521
+ // said. ACTIONS-ONLY posture (audit-wedge build plan §1c, 2026-07-14): a judged silence
2522
+ // is NOT an audited action — the transcript answers "why did it stay quiet?", not the
2523
+ // audit log. The partition is rebuilt from the live state; best-effort, never fails the turn.
2524
+ const auditP = channel.state.channelId
2525
+ ? auditPartitionForGatewayState(channel.state)
2526
+ : null;
2527
+ if (plan.delivery.kind === "post") {
2528
+ const text = action.kind === "post" ? action.text : plan.delivery.chunks.join("\n");
2529
+ // 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This branch used to route the
2530
+ // guild path's terminal reply through `postTerminalProse`, with a raw `postDiscordReply`
2531
+ // fallback for an unidentifiable state. Both are gone: the `reply` tool is the only channel a
2532
+ // seat's words leave by, so a turn that ended in prose and never called `reply` has said
2533
+ // nothing here. The row is the record — no re-send, no queue, no host-side re-post.
2534
+ // WHAT TELLS THE MODEL is the seat prompt: Discord declares `closingProseDropped`, so a
2535
+ // conversational seat here reads the `reply-only` delivery arm, whose copy states that the
2536
+ // turn-final text is discarded — inside its persona when the token is supervisor-shaped, and
2537
+ // through the `delivery` slot when it is not (instructions/access.ts `renderDeliverySlot`).
2538
+ if (auditP) {
2539
+ await dropClosingProse({
2540
+ row: {
2541
+ org: auditP.org,
2542
+ platform: auditP.platform,
2543
+ workspace: auditP.workspace,
2544
+ surface: auditP.surface,
2545
+ principalId: channel.state.oeReplyPrincipalId,
2546
+ },
2547
+ chars: text.length,
2548
+ ...(sessionId ? { sessionId } : {}),
2549
+ anchor: { channelId: channel.channelId || null },
2550
+ log: (m) => log(`${m} (channel ${channel.channelId || "?"})`),
2551
+ });
2552
+ }
2553
+ else {
2554
+ // No resolvable partition, so there is no row to file it under — the drop still happens and
2555
+ // the log line is the whole record.
2556
+ log(`closing prose DROPPED (${text.length}c) — nothing posted, and no partition to audit it under (channel ${channel.channelId || "?"})`);
2557
+ }
2558
+ }
2559
+ else if (action.kind === "silent") {
2560
+ if (data.message)
2561
+ log(`judged silence (NO_REPLY) — nothing posted (channel ${channel.channelId || "?"})`);
2562
+ // OPE-890: the sentinel route's own ALWAYS-ON row — the twin of tools/no_reply.ts's
2563
+ // tool-route `reply.no_reply_observed` row (OPE-649). Only for a GENUINE bare-sentinel
2564
+ // turn (the tool was not called this turn) — a tool-then-sentinel turn already wrote its
2565
+ // own row inside the tool's execute() and must not be double-counted here.
2566
+ if (action.reason === NO_REPLY_SENTINEL_REASON && !noReplyToolCalledThisTurn && auditP) {
2567
+ await auditNoReplySentinelObserved(auditP, sessionId).catch(() => undefined);
2568
+ }
2569
+ }
2570
+ // 3) ENGAGEMENT BOOKKEEPING (Slack parity). A DELIVERED reply PROMOTES the surface to
2571
+ // participating (sticky, streak reset). A JUDGED-SILENT turn (NO_REPLY / empty final)
2572
+ // bumps the warm silent streak and sheds the surface at the limit. Both best-effort and
2573
+ // gated on the seeded guild — a DM / pre-seed session is a no-op.
2574
+ // 🔴 THE PREDICATE IS THE DELIVERY, NOT `action.kind` (OPE-2006). It read `action.kind ===
2575
+ // "post"` while that meant "the closing prose posted". The prose is DROPPED now, so a turn
2576
+ // that ended in text and never called `reply` put NOTHING in the room — and promoting on it
2577
+ // would make a warm name-drop surface sticky-participating on an answer nobody saw, while
2578
+ // the `silent` branch below never runs to shed it. OPE-771 measured that shape at 23 of 36
2579
+ // owed answers. Only a `reply` tool post or an approval card is bytes in the room, which is
2580
+ // the same pair `channels/slack.ts` promotes on.
2581
+ const deliveredThisTurn = repliedThisTurn || cardPostedThisTurn;
2582
+ if (deliveredThisTurn) {
2583
+ await promoteParticipatingOnReply(channel.state);
2584
+ }
2585
+ else if (action.kind === "silent") {
2586
+ await maybeShedWarmSurface(channel.state);
2587
+ }
2588
+ },
2589
+ },
2590
+ });
2591
+ //# sourceMappingURL=discord-gateway.js.map