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