@open-everywhere/provider-slack 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 (151) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +47 -0
  4. package/agent/access/slack-attachment-fetch.ts +168 -0
  5. package/agent/app-manifest.ts +149 -0
  6. package/agent/channels/slack-approval-card.ts +275 -0
  7. package/agent/channels/slack-attachments.ts +195 -0
  8. package/agent/channels/slack-commands.ts +227 -0
  9. package/agent/channels/slack-context.ts +185 -0
  10. package/agent/channels/slack-events.ts +5406 -0
  11. package/agent/channels/slack-front.ts +2349 -0
  12. package/agent/channels/slack-identity.ts +520 -0
  13. package/agent/channels/slack-input-response.ts +162 -0
  14. package/agent/channels/slack-mrkdwn.ts +61 -0
  15. package/agent/channels/slack-origin-codec.ts +62 -0
  16. package/agent/channels/slack-post-arm.ts +314 -0
  17. package/agent/channels/slack-read.ts +248 -0
  18. package/agent/channels/slack-route-hold.ts +107 -0
  19. package/agent/channels/slack-shortcut.ts +336 -0
  20. package/agent/channels/slack-signature.ts +53 -0
  21. package/agent/channels/slack-spend-gate.ts +62 -0
  22. package/agent/channels/slack-tenant-fence.ts +99 -0
  23. package/agent/channels/slack-wake-backscroll.ts +604 -0
  24. package/agent/channels/slack-web-api.ts +178 -0
  25. package/agent/channels/slack-webhook-verifier.ts +116 -0
  26. package/agent/channels/slack.ts +1266 -0
  27. package/agent/contributions.ts +32 -0
  28. package/agent/docs-generated.ts +15 -0
  29. package/agent/ops.ts +50 -0
  30. package/agent/provider-events.ts +91 -0
  31. package/agent/register.ts +144 -0
  32. package/agent/surface-parity.ts +99 -0
  33. package/dist/agent/access/slack-attachment-fetch.d.ts +15 -0
  34. package/dist/agent/access/slack-attachment-fetch.d.ts.map +1 -0
  35. package/dist/agent/access/slack-attachment-fetch.js +151 -0
  36. package/dist/agent/access/slack-attachment-fetch.js.map +1 -0
  37. package/dist/agent/app-manifest.d.ts +15 -0
  38. package/dist/agent/app-manifest.d.ts.map +1 -0
  39. package/dist/agent/app-manifest.js +141 -0
  40. package/dist/agent/app-manifest.js.map +1 -0
  41. package/dist/agent/channels/slack-approval-card.d.ts +71 -0
  42. package/dist/agent/channels/slack-approval-card.d.ts.map +1 -0
  43. package/dist/agent/channels/slack-approval-card.js +232 -0
  44. package/dist/agent/channels/slack-approval-card.js.map +1 -0
  45. package/dist/agent/channels/slack-attachments.d.ts +66 -0
  46. package/dist/agent/channels/slack-attachments.d.ts.map +1 -0
  47. package/dist/agent/channels/slack-attachments.js +174 -0
  48. package/dist/agent/channels/slack-attachments.js.map +1 -0
  49. package/dist/agent/channels/slack-commands.d.ts +27 -0
  50. package/dist/agent/channels/slack-commands.d.ts.map +1 -0
  51. package/dist/agent/channels/slack-commands.js +208 -0
  52. package/dist/agent/channels/slack-commands.js.map +1 -0
  53. package/dist/agent/channels/slack-context.d.ts +26 -0
  54. package/dist/agent/channels/slack-context.d.ts.map +1 -0
  55. package/dist/agent/channels/slack-context.js +178 -0
  56. package/dist/agent/channels/slack-context.js.map +1 -0
  57. package/dist/agent/channels/slack-events.d.ts +1654 -0
  58. package/dist/agent/channels/slack-events.d.ts.map +1 -0
  59. package/dist/agent/channels/slack-events.js +4240 -0
  60. package/dist/agent/channels/slack-events.js.map +1 -0
  61. package/dist/agent/channels/slack-front.d.ts +387 -0
  62. package/dist/agent/channels/slack-front.d.ts.map +1 -0
  63. package/dist/agent/channels/slack-front.js +1958 -0
  64. package/dist/agent/channels/slack-front.js.map +1 -0
  65. package/dist/agent/channels/slack-identity.d.ts +245 -0
  66. package/dist/agent/channels/slack-identity.d.ts.map +1 -0
  67. package/dist/agent/channels/slack-identity.js +405 -0
  68. package/dist/agent/channels/slack-identity.js.map +1 -0
  69. package/dist/agent/channels/slack-input-response.d.ts +26 -0
  70. package/dist/agent/channels/slack-input-response.d.ts.map +1 -0
  71. package/dist/agent/channels/slack-input-response.js +127 -0
  72. package/dist/agent/channels/slack-input-response.js.map +1 -0
  73. package/dist/agent/channels/slack-mrkdwn.d.ts +3 -0
  74. package/dist/agent/channels/slack-mrkdwn.d.ts.map +1 -0
  75. package/dist/agent/channels/slack-mrkdwn.js +53 -0
  76. package/dist/agent/channels/slack-mrkdwn.js.map +1 -0
  77. package/dist/agent/channels/slack-origin-codec.d.ts +7 -0
  78. package/dist/agent/channels/slack-origin-codec.d.ts.map +1 -0
  79. package/dist/agent/channels/slack-origin-codec.js +55 -0
  80. package/dist/agent/channels/slack-origin-codec.js.map +1 -0
  81. package/dist/agent/channels/slack-post-arm.d.ts +54 -0
  82. package/dist/agent/channels/slack-post-arm.d.ts.map +1 -0
  83. package/dist/agent/channels/slack-post-arm.js +268 -0
  84. package/dist/agent/channels/slack-post-arm.js.map +1 -0
  85. package/dist/agent/channels/slack-read.d.ts +84 -0
  86. package/dist/agent/channels/slack-read.d.ts.map +1 -0
  87. package/dist/agent/channels/slack-read.js +205 -0
  88. package/dist/agent/channels/slack-read.js.map +1 -0
  89. package/dist/agent/channels/slack-route-hold.d.ts +34 -0
  90. package/dist/agent/channels/slack-route-hold.d.ts.map +1 -0
  91. package/dist/agent/channels/slack-route-hold.js +97 -0
  92. package/dist/agent/channels/slack-route-hold.js.map +1 -0
  93. package/dist/agent/channels/slack-shortcut.d.ts +162 -0
  94. package/dist/agent/channels/slack-shortcut.d.ts.map +1 -0
  95. package/dist/agent/channels/slack-shortcut.js +262 -0
  96. package/dist/agent/channels/slack-shortcut.js.map +1 -0
  97. package/dist/agent/channels/slack-signature.d.ts +19 -0
  98. package/dist/agent/channels/slack-signature.d.ts.map +1 -0
  99. package/dist/agent/channels/slack-signature.js +50 -0
  100. package/dist/agent/channels/slack-signature.js.map +1 -0
  101. package/dist/agent/channels/slack-spend-gate.d.ts +22 -0
  102. package/dist/agent/channels/slack-spend-gate.d.ts.map +1 -0
  103. package/dist/agent/channels/slack-spend-gate.js +48 -0
  104. package/dist/agent/channels/slack-spend-gate.js.map +1 -0
  105. package/dist/agent/channels/slack-tenant-fence.d.ts +61 -0
  106. package/dist/agent/channels/slack-tenant-fence.d.ts.map +1 -0
  107. package/dist/agent/channels/slack-tenant-fence.js +88 -0
  108. package/dist/agent/channels/slack-tenant-fence.js.map +1 -0
  109. package/dist/agent/channels/slack-wake-backscroll.d.ts +188 -0
  110. package/dist/agent/channels/slack-wake-backscroll.d.ts.map +1 -0
  111. package/dist/agent/channels/slack-wake-backscroll.js +473 -0
  112. package/dist/agent/channels/slack-wake-backscroll.js.map +1 -0
  113. package/dist/agent/channels/slack-web-api.d.ts +46 -0
  114. package/dist/agent/channels/slack-web-api.d.ts.map +1 -0
  115. package/dist/agent/channels/slack-web-api.js +147 -0
  116. package/dist/agent/channels/slack-web-api.js.map +1 -0
  117. package/dist/agent/channels/slack-webhook-verifier.d.ts +5 -0
  118. package/dist/agent/channels/slack-webhook-verifier.d.ts.map +1 -0
  119. package/dist/agent/channels/slack-webhook-verifier.js +112 -0
  120. package/dist/agent/channels/slack-webhook-verifier.js.map +1 -0
  121. package/dist/agent/channels/slack.d.ts +37 -0
  122. package/dist/agent/channels/slack.d.ts.map +1 -0
  123. package/dist/agent/channels/slack.js +1169 -0
  124. package/dist/agent/channels/slack.js.map +1 -0
  125. package/dist/agent/contributions.d.ts +17 -0
  126. package/dist/agent/contributions.d.ts.map +1 -0
  127. package/dist/agent/contributions.js +32 -0
  128. package/dist/agent/contributions.js.map +1 -0
  129. package/dist/agent/docs-generated.d.ts +3 -0
  130. package/dist/agent/docs-generated.d.ts.map +1 -0
  131. package/dist/agent/docs-generated.js +13 -0
  132. package/dist/agent/docs-generated.js.map +1 -0
  133. package/dist/agent/ops.d.ts +3 -0
  134. package/dist/agent/ops.d.ts.map +1 -0
  135. package/dist/agent/ops.js +38 -0
  136. package/dist/agent/ops.js.map +1 -0
  137. package/dist/agent/provider-events.d.ts +3 -0
  138. package/dist/agent/provider-events.d.ts.map +1 -0
  139. package/dist/agent/provider-events.js +84 -0
  140. package/dist/agent/provider-events.js.map +1 -0
  141. package/dist/agent/register.d.ts +5 -0
  142. package/dist/agent/register.d.ts.map +1 -0
  143. package/dist/agent/register.js +118 -0
  144. package/dist/agent/register.js.map +1 -0
  145. package/dist/agent/surface-parity.d.ts +3 -0
  146. package/dist/agent/surface-parity.d.ts.map +1 -0
  147. package/dist/agent/surface-parity.js +95 -0
  148. package/dist/agent/surface-parity.js.map +1 -0
  149. package/package.json +209 -0
  150. package/tsconfig.build.json +22 -0
  151. package/tsconfig.json +15 -0
@@ -0,0 +1,1958 @@
1
+ // Component 2 wake path — MOUNTED).
2
+ //
3
+ // Slack delivers EVERY subscribed event to ONE Request URL. Eve's `slackChannel` owns that
4
+ // URL and only acts on `app_mention` + `message.im` (DM); a plain `message.channels` reply is
5
+ // silently dropped, and Eve exposes no `onMessage` hook. So to get "reply without re-tagging"
6
+ // we FRONT the public URL with our own route and forward the events slackChannel handles to
7
+ // its own handler — no dispatch fork (option (b)), no upstream dependency (option (a)).
8
+ //
9
+ // The design (why it's clean):
10
+ // • The wrapped `slackChannel` is pointed at an INTERNAL route (`/eve/v1/slack/mention`).
11
+ // • OUR route sits at the PUBLIC `/eve/v1/slack` (the URL Slack POSTs to).
12
+ // • Both routes live on the SAME channel object (one file → one channel name → ONE session
13
+ // namespace and ONE `send`). So forwarding `args` straight through gives slackChannel the
14
+ // exact `send`/continuation machinery it would get if mounted directly, AND our wake uses
15
+ // the same `send` → `slackContinuationToken(channel, thread)` continues the very session
16
+ // the mention started. That shared namespace is the whole reason (c) works.
17
+ //
18
+ // The front route:
19
+ // 1. Buffers the RAW body ONCE (needed for BOTH signature verification AND forwarding).
20
+ // 2. Verifies the Slack signature over that raw body (fail-closed 401). We re-implement the
21
+ // HMAC here because Eve's `verifySlackRequest` isn't a public export; slackChannel still
22
+ // re-verifies on the forward path (a fresh Request re-reads the body), so the mention
23
+ // path's auth is unchanged — we only ADD a gate, never weaken one.
24
+ // 3. Classifies the envelope: url_verification / block_actions interaction → FORWARD to
25
+ // slackChannel's handler; message.im and message.mpim (the DM FAMILY) → divert into the
26
+ // surface's own standing DM window (OPE-351 stages 2 + 4), never forwarded; app_mention →
27
+ // FORWARD too, UNLESS either the single-window mention policy (unified-window.ts seam) says
28
+ // the unified window owns it — then the app_mention EVENT ITSELF is diverted to the unified
29
+ // supervisor as an addressed direct-mention wake (single-leg: ONE decision on ONE webhook),
30
+ // and its message.channels sibling is dropped exactly as always — or the mention landed in a
31
+ // GROUP DM, which the envelope does not say and a conversations.info read has to answer, in
32
+ // which case it diverts into that mpim's DM window and its message.mpim sibling is dropped;
33
+ // plain message.channels/groups → run the engagement wake (routeMessageEvent);
34
+ // member_joined_channel (the bot's own join) → the channel-join intro
35
+ // (routeChannelJoinEvent); a urlencoded `block_actions` payload whose actions carry an
36
+ // `oe_approval:` action id → OURS,
37
+ // handled locally via `handleApprovalPayload` and NEVER forwarded — every other
38
+ // urlencoded body (Eve's own `eve_input:` HITL clicks, view_submission, slash
39
+ // commands) still forwards exactly as before.
40
+ // 4. Idempotency + per-thread FIFO guard the wake (see EventDedupe / ThreadFifo below).
41
+ import { POST } from "eve/channels";
42
+ import { SLACK_SIGNATURE_HEADER, SLACK_TIMESTAMP_HEADER, slackSignature, slackSignatureMatches } from "./slack-signature.js";
43
+ import { slackContinuationToken } from "eve/channels/slack";
44
+ import { callSlackWebApi } from "./slack-web-api.js";
45
+ import { auditDmBotAuthorDrop, auditDmDriverAdmitted, dmDoorAdmitsDriver, parseMessageEvent, routeMessageEvent, liveDeps, routeNameDropEvent, liveNameDropDeps, parseChannelJoinEvent, routeChannelJoinEvent, liveChannelJoinDeps, parseSlackReactionEvent, routeReactionEvent, liveReactionDeps, routeSupervisorEvent, liveSupervisorDeps, supervisorFifoKey, bindableFor, bindableForJoin, bindableForReaction, slackSingleWindowMentionPolicy, slackDomainUsesUnifiedWindow, slackFrontTenantFenceDeclined, slackInteractivityTenantFenceDeclined, } from "./slack-events.js";
46
+ import { slackInteractivityTenantIds } from "./slack-tenant-fence.js";
47
+ import { EventDedupe, ThreadFifo, getOrCreateDmSeat as liveGetOrCreateDmSeat, touchDmSeat as liveTouchDmSeat, resolvePartition, domainKeyOf, isThreadEngaged, divertInboundToUnifiedWindow, isPrunedDeploymentWakeError, markUndeliveredAfterHeal, isUndeliveredAfterHeal, healPrunedDeploymentStrand, mintWakeId, slackVendorEventTime, spreadVendorTs, resolveWakeTrust, ambientSupervisorEnabled, getConfig, getOperationalStore, OE_APPROVAL_PREFIX, handleApprovalPayload as liveHandleApprovalPayload, OE_SETKEY_ACTION_PREFIX, OE_SETKEY_MODAL_CALLBACK, handleSetKeyClick as liveHandleSetKeyClick, handleSetKeyViewSubmission as liveHandleSetKeyViewSubmission, OE_CONNECT_ACTION_PREFIX, handleConnectClick as liveHandleConnectClick, OE_GRANT_ACTION_PREFIX, OE_INPUT_CLICK_PREFIX, handleGrantCardClick as liveHandleGrantCardClick, buildRecord, enqueue as enqueueLogRecord, flushLogShip, stampNonMemberChatAuth, externalPolicyIsInert, recordWakeTail, tailAuthorGrain, dmWindowRegistryKey, approvalCardRespondVia, } from "@open-everywhere/core/agent/adapter-api.js";
48
+ import { renderWakeMessage, slackChannelFactsFor, slackAuthorIsMember, slackClickerIsGuest, slackWakeAuthorFacts, slackExternalPolicyForRoom, applySlackExternalPolicy, stripUnfencedSlackAttachments, slackEnterpriseIdFromEnvelope, _resetAuthorMemberCache, } from "./slack-events.js";
49
+ import { buildSlackAuth, withForwardedSlackEnvelope } from "./slack-identity.js";
50
+ import { holdRouteWork, routeHoldQuiesced, withSlackRouteHold } from "./slack-route-hold.js";
51
+ import { persistSlackAttachment, registerSlackAttachment } from "./slack-attachments.js";
52
+ import { handleSlackApprovalCardClick as liveHandleApprovalCardClick, } from "./slack-approval-card.js";
53
+ import { parseSlackShortcutPayload, shortcutMessageEvent, shortcutWakeKey, } from "./slack-shortcut.js";
54
+ /** The public URL Slack POSTs every subscribed event to (Event Subscriptions Request URL). */
55
+ export const PUBLIC_SLACK_ROUTE = "/eve/v1/slack";
56
+ /** The internal path the wrapped `slackChannel` is mounted at (never hit by Slack directly). */
57
+ export const INTERNAL_SLACK_ROUTE = "/eve/v1/slack/mention";
58
+ /**
59
+ * Best-effort decode of a urlencoded interactivity body into an `oe_approval:` block_actions
60
+ * payload — `undefined` for anything that isn't one (no `payload` field, unparseable JSON, a
61
+ * non-`block_actions` type, or a `block_actions` whose action ids are all someone else's, e.g.
62
+ * Eve's own `eve_input:` HITL buttons). Never throws: any parse failure just means "not ours".
63
+ */
64
+ function parseOeApprovalPayload(rawBody) {
65
+ const raw = new URLSearchParams(rawBody).get("payload");
66
+ if (!raw)
67
+ return undefined;
68
+ let payload;
69
+ try {
70
+ payload = JSON.parse(raw);
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ if (typeof payload !== "object" || payload === null)
76
+ return undefined;
77
+ const p = payload;
78
+ if (p.type !== "block_actions" || !Array.isArray(p.actions))
79
+ return undefined;
80
+ const isOurs = p.actions.some((a) => typeof a === "object" &&
81
+ a !== null &&
82
+ typeof a.action_id === "string" &&
83
+ a.action_id.startsWith(OE_APPROVAL_PREFIX));
84
+ return isOurs ? payload : undefined;
85
+ }
86
+ /** Decode the interactivity `payload` field into a plain object, or undefined if it isn't parseable
87
+ * JSON-in-urlencoded. Shared by the setkey click + view_submission classifiers below. */
88
+ function decodeInteractivityPayload(rawBody) {
89
+ const raw = new URLSearchParams(rawBody).get("payload");
90
+ if (!raw)
91
+ return undefined;
92
+ let payload;
93
+ try {
94
+ payload = JSON.parse(raw);
95
+ }
96
+ catch {
97
+ return undefined;
98
+ }
99
+ return typeof payload === "object" && payload !== null ? payload : undefined;
100
+ }
101
+ /**
102
+ * Best-effort decode of a urlencoded body into an `oe_setkey:` "Set API key" `block_actions`
103
+ * payload (credentials/intake.ts) — undefined for anything that isn't one. Never throws.
104
+ */
105
+ function parseOeSetKeyClickPayload(rawBody) {
106
+ const p = decodeInteractivityPayload(rawBody);
107
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
108
+ return undefined;
109
+ const isOurs = p.actions.some((a) => typeof a === "object" &&
110
+ a !== null &&
111
+ typeof a.action_id === "string" &&
112
+ a.action_id.startsWith(OE_SETKEY_ACTION_PREFIX));
113
+ return isOurs ? p : undefined;
114
+ }
115
+ /**
116
+ * Best-effort decode of a urlencoded body into an `oe_connect:` connector-card `block_actions` payload
117
+ * (connections/connector-card.ts) — undefined for anything that isn't one. Never throws.
118
+ */
119
+ function parseOeConnectClickPayload(rawBody) {
120
+ const p = decodeInteractivityPayload(rawBody);
121
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
122
+ return undefined;
123
+ const isOurs = p.actions.some((a) => typeof a === "object" &&
124
+ a !== null &&
125
+ typeof a.action_id === "string" &&
126
+ a.action_id.startsWith(OE_CONNECT_ACTION_PREFIX));
127
+ return isOurs ? p : undefined;
128
+ }
129
+ /**
130
+ * Best-effort decode of a urlencoded body into an `oe_grant:` credential-grant card `block_actions`
131
+ * payload (access/credential-grant-card.ts) — undefined for anything that isn't one. Never throws.
132
+ */
133
+ function parseOeGrantClickPayload(rawBody) {
134
+ const p = decodeInteractivityPayload(rawBody);
135
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
136
+ return undefined;
137
+ const isOurs = p.actions.some((a) => typeof a === "object" &&
138
+ a !== null &&
139
+ typeof a.action_id === "string" &&
140
+ a.action_id.startsWith(OE_GRANT_ACTION_PREFIX));
141
+ return isOurs ? p : undefined;
142
+ }
143
+ /**
144
+ * Best-effort decode of a urlencoded body into an `oe_input:` bridged-approval-card `block_actions`
145
+ * payload (core channels/approval-card.ts) — undefined for anything that isn't one. Never throws.
146
+ *
147
+ * 🔴 NOT eve's `eve_input:`, which still FORWARDS. The two prefixes are one character apart on
148
+ * purpose — both name an input request — and only one of them is ours. `startsWith` distinguishes
149
+ * them cleanly because neither is a prefix of the other, and the order of the two branches never
150
+ * matters: eve's clicks reach no branch here at all and fall to the urlencoded forward.
151
+ */
152
+ function parseOeInputClickPayload(rawBody) {
153
+ const p = decodeInteractivityPayload(rawBody);
154
+ if (!p || p.type !== "block_actions" || !Array.isArray(p.actions))
155
+ return undefined;
156
+ const isOurs = p.actions.some((a) => typeof a === "object" &&
157
+ a !== null &&
158
+ typeof a.action_id === "string" &&
159
+ a.action_id.startsWith(OE_INPUT_CLICK_PREFIX));
160
+ return isOurs ? p : undefined;
161
+ }
162
+ /**
163
+ * Best-effort decode of a urlencoded body into OUR credential-intake `view_submission` (its
164
+ * `view.callback_id` is our modal's) — undefined for any other view_submission (Eve's own modals
165
+ * still forward). Never throws.
166
+ */
167
+ function parseOeSetKeyViewSubmission(rawBody) {
168
+ const p = decodeInteractivityPayload(rawBody);
169
+ if (!p || p.type !== "view_submission")
170
+ return undefined;
171
+ const view = p.view;
172
+ return view?.callback_id === OE_SETKEY_MODAL_CALLBACK ? p : undefined;
173
+ }
174
+ /**
175
+ * The ids one already-decoded INTERACTIVITY payload names, for the tenant fence at the six click
176
+ * branches (OPE-1680). The tenant half comes from {@link slackInteractivityTenantIds}, which is the
177
+ * one reader of Slack's three Grid spellings; the room half is read here because only the fence's
178
+ * AUDIT ROW wants it — the admission decision never turns on a channel.
179
+ *
180
+ * A `view_submission` names no `channel` at all (it names a view), which is exactly why the row
181
+ * writer takes an optional channel rather than demanding one.
182
+ */
183
+ function interactivityFenceIds(payload) {
184
+ const p = payload;
185
+ const obj = (v) => (typeof v === "object" && v !== null ? v : undefined);
186
+ const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
187
+ const channelId = str(obj(p?.channel)?.id);
188
+ const userId = str(obj(p?.user)?.id);
189
+ return {
190
+ ...slackInteractivityTenantIds(payload),
191
+ ...(channelId === undefined ? {} : { channelId }),
192
+ ...(userId === undefined ? {} : { userId }),
193
+ };
194
+ }
195
+ /**
196
+ * Decide how the front route handles a raw inbound body. FORWARD is the fail-safe default:
197
+ * anything slackChannel owns (the challenge handshake, `app_mention`, a `block_actions`/slash
198
+ * interaction) or anything unrecognized goes to slackChannel unchanged. The DM FAMILY is ours:
199
+ * `message.im` (OPE-351 stage 2) and `message.mpim` (stage 4) classify `dm-wake` and are diverted
200
+ * into the surface's own standing window. The other events slackChannel drops route to us too: a
201
+ * plain channel/group `message` → the engagement wake, `member_joined_channel` → the channel-join
202
+ * intro, and a urlencoded `block_actions` payload carrying an `oe_approval:` action id → the
203
+ * approval-click handler (ours alone). A urlencoded SHORTCUT payload (`message_action` /
204
+ * `shortcut`, OPE-1376) is ours too — every OTHER urlencoded body, including Eve's `eve_input:`
205
+ * clicks, its view_submissions and slash commands, still forwards. No network I/O and NO side
206
+ * effects, so every branch is unit-tested. Classification decides what a request IS; nothing here
207
+ * may write process state, because every gate that decides whether the request WAKES anything runs
208
+ * later in the handler (OPE-1442). The absence of a lookup is also why the
209
+ * GROUP-DM app_mention leg is decided in the HANDLER and not here: it needs a Slack lookup, because
210
+ * an app_mention envelope carries no `channel_type` to classify on.
211
+ */
212
+ export function classifyEnvelope(rawBody, contentType) {
213
+ // Interactions (button clicks, slash commands) arrive urlencoded (`payload=...`).
214
+ if (contentType.includes("application/x-www-form-urlencoded")) {
215
+ const approvalPayload = parseOeApprovalPayload(rawBody);
216
+ if (approvalPayload !== undefined) {
217
+ return { kind: "approval-click", reason: "block_actions oe_approval: click — ours, never forwarded", payload: approvalPayload };
218
+ }
219
+ const setKeyClick = parseOeSetKeyClickPayload(rawBody);
220
+ if (setKeyClick !== undefined) {
221
+ return { kind: "setkey-click", reason: "block_actions oe_setkey: click — ours, never forwarded", payload: setKeyClick };
222
+ }
223
+ const setKeySubmit = parseOeSetKeyViewSubmission(rawBody);
224
+ if (setKeySubmit !== undefined) {
225
+ return { kind: "setkey-submit", reason: "view_submission for our credential-intake modal — ours, never forwarded", payload: setKeySubmit };
226
+ }
227
+ const connectClick = parseOeConnectClickPayload(rawBody);
228
+ if (connectClick !== undefined) {
229
+ return { kind: "connect-click", reason: "block_actions oe_connect: click — ours, never forwarded", payload: connectClick };
230
+ }
231
+ const grantClick = parseOeGrantClickPayload(rawBody);
232
+ if (grantClick !== undefined) {
233
+ return { kind: "grant-click", reason: "block_actions oe_grant: click — ours, never forwarded", payload: grantClick };
234
+ }
235
+ const inputClick = parseOeInputClickPayload(rawBody);
236
+ if (inputClick !== undefined) {
237
+ return { kind: "input-click", reason: "block_actions oe_input: click — ours, never forwarded", payload: inputClick };
238
+ }
239
+ // A SHORTCUT (OPE-1376) — a `message_action` or `shortcut` payload. Ours, never forwarded; see
240
+ // the `shortcut` arm of FrontDecision for why a forward could not do anything with it. It is
241
+ // matched LAST among the urlencoded branches and cannot race any of them: every branch above
242
+ // reads a `block_actions` or a `view_submission`, and a shortcut is neither.
243
+ const shortcut = parseSlackShortcutPayload(decodeInteractivityPayload(rawBody));
244
+ if (shortcut) {
245
+ return { kind: "shortcut", reason: `${shortcut.kind} shortcut "${shortcut.callbackId}" — ours, never forwarded`, shortcut };
246
+ }
247
+ return { kind: "forward", reason: "urlencoded interaction/slash — slackChannel owns it" };
248
+ }
249
+ let env;
250
+ try {
251
+ env = JSON.parse(rawBody);
252
+ }
253
+ catch {
254
+ return { kind: "forward", reason: "unparseable body — let slackChannel ack it" };
255
+ }
256
+ if (env?.type === "url_verification") {
257
+ return { kind: "forward", reason: "url_verification challenge — slackChannel answers it" };
258
+ }
259
+ if (env?.type !== "event_callback") {
260
+ return { kind: "forward", reason: `outer type "${String(env?.type)}" — not an event we wake on` };
261
+ }
262
+ const eventType = typeof env.event?.type === "string" ? env.event.type : undefined;
263
+ if (eventType === "app_mention") {
264
+ // Not a bare "forward": the handler must first ask the single-window policy whether the
265
+ // unified window owns this mention (suppress the native forward) — the default answer is
266
+ // "forward to the mention path", byte-identical to before the branch existed.
267
+ return { kind: "mention", reason: "app_mention — the mention path owns it unless single-window suppresses", env };
268
+ }
269
+ if (eventType === "member_joined_channel") {
270
+ return { kind: "join", reason: "member_joined_channel — channel-join intro candidate", env };
271
+ }
272
+ if (eventType === "reaction_added" || eventType === "reaction_removed") {
273
+ // Inbound reactions: slackChannel drops them (no handler), so they're ours to ingest
274
+ // (reaction-ingest.ts — a feedback signal on the bot's own replies + volume telemetry).
275
+ return { kind: "reaction", reason: `${eventType} — reaction ingestion candidate`, env };
276
+ }
277
+ if (eventType !== "message") {
278
+ return { kind: "forward", reason: `event "${String(eventType)}" — not a message` };
279
+ }
280
+ const channelType = typeof env.event.channel_type === "string"
281
+ ? env.event.channel_type
282
+ : undefined;
283
+ if (channelType === "im") {
284
+ // OPE-351 stage 2: a 1:1 DM is no longer forwarded to eve's native `onDirectMessage`. Its seat is
285
+ // a first-class standing WINDOW (channels/dm-seat.ts), so the DM is diverted onto the `unified`
286
+ // channel exactly like the single-window app_mention above — one decision, one webhook.
287
+ return { kind: "dm-wake", reason: "message.im (DM) — the DM window seat owns it", env, dmKind: "im" };
288
+ }
289
+ if (channelType === "mpim") {
290
+ // OPE-351 stage 4: a GROUP DM joins the family. Its previous route was the engagement wake below,
291
+ // where `shouldNameDropWake` refused it outright on channel_type — so a non-mention message in a
292
+ // group DM woke NOTHING, and the room's only live path was an `app_mention` forwarded to eve's
293
+ // native per-thread session. Both halves move into the mpim's own standing window (the mention
294
+ // half in the `mention` branch of the handler, which has to ASK Slack what surface it landed on —
295
+ // an app_mention envelope carries no channel_type).
296
+ return { kind: "dm-wake", reason: "message.mpim (group DM) — the DM window seat owns it", env, dmKind: "mpim" };
297
+ }
298
+ // channels / groups — the plain reply slackChannel drops. Ours to wake.
299
+ return { kind: "wake", reason: `message.${channelType ?? "?"} — engagement wake candidate`, env };
300
+ }
301
+ /** The FIFO key a DM's wakes serialize on. Generation-INDEPENDENT (the same reasoning as
302
+ * `supervisorFifoKey`): the FIFO's job is to serialize this DM's wakes, which must hold ACROSS a
303
+ * rotation boundary too, so it keys on the channel and never on the generation-bearing token. Its
304
+ * own namespace, so a DM's queue is never shared with a channel supervisor's or a thread's. */
305
+ export function dmFifoKey(dmChannelId) {
306
+ return `${dmChannelId}:oe-dm`;
307
+ }
308
+ // --- signature verification (Slack HMAC) ------------------------------------
309
+ const SLACK_MAX_SKEW_SECONDS = 60 * 5; // Slack's own replay window.
310
+ /**
311
+ * Verify a Slack request signature over the RAW body (the exact bytes Slack signed).
312
+ * `v0=` HMAC-SHA256 of `v0:{timestamp}:{body}` keyed by the signing secret, constant-time
313
+ * compared, with a 5-minute replay window. Fail-closed: any missing header / secret, a stale
314
+ * timestamp, or a mismatch returns false. `nowMs` is injectable so the replay window is tested.
315
+ *
316
+ * The BASESTRING AND DIGEST are `channels/slack-signature.ts`'s (OPE-197), shared verbatim with the
317
+ * webhook-registry entry for this scheme — a difference there would be a signature that verifies on
318
+ * one route and 401s on the other. What stays HERE is what the two routes legitimately differ on:
319
+ * this one reads one env-homed secret and returns a bare boolean, because a Slack Request URL has no
320
+ * failure vocabulary to report into.
321
+ */
322
+ export function verifySlackSignature(rawBody, headers, signingSecret, nowMs = Date.now()) {
323
+ const timestamp = headers.get(SLACK_TIMESTAMP_HEADER);
324
+ const signature = headers.get(SLACK_SIGNATURE_HEADER);
325
+ if (!signingSecret || !timestamp || !signature)
326
+ return false;
327
+ const ts = Number(timestamp);
328
+ if (!Number.isFinite(ts) || Math.abs(nowMs / 1000 - ts) > SLACK_MAX_SKEW_SECONDS)
329
+ return false;
330
+ return slackSignatureMatches(signingSecret, timestamp, rawBody, signature);
331
+ }
332
+ // --- retry de-dupe + per-thread FIFO: LIFTED to core (OPE-114 stage 4) ----------------------
333
+ // EventDedupe and ThreadFifo are provider-neutral front-door plumbing (teams-front rides them
334
+ // too) — they now live in core channels/front-queue.ts, imported through the barrel below and
335
+ // re-exported here so bundle-internal import sites (slack-commands, tests) are unchanged.
336
+ export { EventDedupe, ThreadFifo };
337
+ // --- bot identity (mention suppression) -------------------------------------
338
+ /**
339
+ * The bot's own Slack user id, resolved once via `auth.test` (cached). Needed because Slack
340
+ * fires BOTH `app_mention` AND `message.channels` for a message that @mentions the app; the
341
+ * mention path already owns the `app_mention`, so on the wake path we DROP a message whose
342
+ * AUTHORED text still carries `<@bot>` to avoid a double turn. Authored, not the parsed `text`:
343
+ * that now carries forwarded content too, and a forward quoting a bot mention must still wake
344
+ * (OPE-1374). Best-effort: if it can't be resolved
345
+ * (no token / API error) we don't suppress — an occasional double is better than dropping a
346
+ * genuine engaged reply. `SLACK_BOT_TOKEN` is already required for the bot to post at all, so
347
+ * in practice this resolves.
348
+ */
349
+ let botIdentityPromise;
350
+ export function resolveBotIdentity() {
351
+ if (!botIdentityPromise) {
352
+ botIdentityPromise = (async () => {
353
+ try {
354
+ // Via OE's `callSlackWebApi` (the 429-backoff seam); a missing token makes it throw, and any
355
+ // throw → {} (best-effort — we simply don't suppress on failure).
356
+ const json = (await callSlackWebApi({ botToken: undefined, operation: "auth.test", body: {} }));
357
+ return json.ok ? { userId: json.user_id, botId: json.bot_id } : {};
358
+ }
359
+ catch {
360
+ return {};
361
+ }
362
+ })();
363
+ }
364
+ return botIdentityPromise;
365
+ }
366
+ /** Reset the cached identity (tests only). */
367
+ export function _resetBotIdentity() {
368
+ botIdentityPromise = undefined;
369
+ }
370
+ const OK = () => new Response("ok");
371
+ /**
372
+ * The Enterprise Grid rung a forwarded delivery carries, paired with the message it describes
373
+ * (OPE-1559). `undefined` for an ordinary single-workspace install, for an interactivity body, and
374
+ * for anything this route could not parse — the native forward then stows nothing and `slack.ts`
375
+ * stamps no rung, exactly as before.
376
+ *
377
+ * The rung itself comes from {@link slackEnterpriseIdFromEnvelope}, the ONE reader of
378
+ * `enterprise_id`; this function only pairs it with the inner event's `(channel, ts)`, which is what
379
+ * lets `forwardedSlackEnterpriseId` (slack-identity.ts) answer for that message alone. A Grid
380
+ * envelope whose inner event names neither returns `undefined`: an unpairable rung is a rung this
381
+ * route will not carry.
382
+ */
383
+ function forwardedEnvelopeOf(rawBody) {
384
+ let parsed;
385
+ try {
386
+ parsed = JSON.parse(rawBody);
387
+ }
388
+ catch {
389
+ return undefined;
390
+ }
391
+ // `JSON.parse` answers `null` for the body `null`, and a scalar for `1` or `"x"` — none of which
392
+ // throws, and all of which would make the field reads below throw INSTEAD OF returning undefined.
393
+ // A throw here escapes `forwardNative` and turns a forward that used to ack 200 into a 500 that
394
+ // Slack retries, so the shape is checked rather than assumed.
395
+ if (parsed === null || typeof parsed !== "object")
396
+ return undefined;
397
+ const env = parsed;
398
+ const enterpriseId = slackEnterpriseIdFromEnvelope(env);
399
+ if (enterpriseId === undefined)
400
+ return undefined;
401
+ const event = env.event;
402
+ const channelId = typeof event?.channel === "string" ? event.channel : undefined;
403
+ const ts = typeof event?.ts === "string" ? event.ts : undefined;
404
+ if (!channelId || !ts)
405
+ return undefined;
406
+ return { enterpriseId, channelId, ts };
407
+ }
408
+ /** The event_id (retry-stable) or `(channel, ts)` key we de-dupe a wake candidate on.
409
+ * Prefixed with the RECEIVING bot identity (constant "default" today): with N Slack apps
410
+ * in one channel (item 27 tier 2) each app receives its own copy of every message, and
411
+ * an un-prefixed key would swallow one app's copy as the other's retry — silently, per
412
+ * message, maddening to debug. The TTL'd dedupe store self-heals across the change. */
413
+ function wakeKey(env, channelId, ts, botIdentity = "default") {
414
+ const eventId = typeof env.event_id === "string" ? env.event_id : undefined;
415
+ return `${botIdentity}:${eventId ?? `${channelId}:${ts}`}`;
416
+ }
417
+ /**
418
+ * Resolve the 1:1 DM channel with one Slack member (OPE-1376's global-shortcut landing place).
419
+ *
420
+ * `conversations.open` is idempotent — repeated calls return the SAME `im` channel id — so there is
421
+ * no "already open" branch, and this opens no conversation a global shortcut's invoker did not
422
+ * themselves ask for by pressing the shortcut.
423
+ *
424
+ * FAIL-CLOSED and QUIET-ON-THE-WIRE: any failure (no token, a workspace without `im:write`, a user
425
+ * who cannot be DM'd) returns `undefined`, which the caller turns into "wake nothing, log why".
426
+ * Returning a guess would put a person's shortcut into a room they did not choose.
427
+ */
428
+ async function liveOpenDirectMessageChannel(userId) {
429
+ try {
430
+ const opened = await callSlackWebApi({ botToken: undefined, operation: "conversations.open", body: { users: userId } });
431
+ if (opened?.ok !== true)
432
+ return undefined;
433
+ const channel = opened.channel;
434
+ return typeof channel?.id === "string" && channel.id.length > 0 ? channel.id : undefined;
435
+ }
436
+ catch {
437
+ return undefined;
438
+ }
439
+ }
440
+ // Durable half of the retry dedupe: the in-memory seen-set only catches a retry landing
441
+ // on the SAME instance; on serverless the redelivery often lands on a fresh one. FAIL-OPEN
442
+ // by design — dedupe prevents an annoyance (a double reply), not a security hole, so a
443
+ // store outage must never silence genuine wakes. TTL matches the in-memory set; Slack
444
+ // stops retrying long before it expires.
445
+ const DEDUPE_TTL_MS = 10 * 60_000;
446
+ let warnedDedupeStore = false;
447
+ async function liveMarkEventSeen(org, key, atIso) {
448
+ try {
449
+ return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
450
+ }
451
+ catch (err) {
452
+ if (!warnedDedupeStore) {
453
+ warnedDedupeStore = true;
454
+ console.warn(`[open-everywhere:slack-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
455
+ }
456
+ return true;
457
+ }
458
+ }
459
+ // Route decisions used to be silently discarded, which made a gate decline, a judged
460
+ // NO_REPLY, and a broken path indistinguishable from the outside (bit us live: "the
461
+ // name-drop didn't reply" had no way to tell WHY). One line per candidate event; the
462
+ // posted reply itself is the positive signal, this line is the story when there isn't one.
463
+ const logOutcome = (path, channelId, ts) => (outcome) => {
464
+ console.log(`[open-everywhere:slack-front] ${path} ${channelId}/${ts}: ${outcome.woke ? "woke" : "no wake"} — ${outcome.reason}`);
465
+ };
466
+ /**
467
+ * Turn a swallowed FIFO wake failure into a DURABLE, queryable signal (resilience S-1 / #551
468
+ * §3.4): a log-ship "error" record (the primary win — the failure survives the ~1h console
469
+ * retention) plus a best-effort `surface.wake_failed` audit row (mirrors recordQuietFailure's
470
+ * posture). The FIFO key encodes the channel (`${channelId}:${threadTs}` for a thread wake,
471
+ * `${channelId}:oe-sup` for a supervisor wake); team is unknown at this layer, so the audit
472
+ * partition falls back to the empty-team default exactly like recordQuietFailure's own
473
+ * `teamId ?? ""` — best-effort, never blocks. Never throws.
474
+ */
475
+ /**
476
+ * Deliver a wake, and RE-DELIVER IT ONCE when the first attempt died on a pruned deployment
477
+ * (OPE-963, channels/stranded-seat.ts).
478
+ *
479
+ * The first attempt's failure is not wasted: `divertToUnifiedWindow` classified it and MARKED the
480
+ * seat's `channel_session` row for rotation before it rethrew. So the second attempt re-resolves the
481
+ * seat, `rotationTrigger` answers `operator-marked`, the seat takes its own rotate leg (fresh
482
+ * generation, carried tail), and eve opens a NEW run under the CURRENT deployment. Without this the
483
+ * member loses the message that hit the strand, even though the seat itself is healed.
484
+ *
485
+ * EXACTLY ONE retry, and only for this class. Every other failure rethrows untouched, straight to
486
+ * the FIFO's loud handler ({@link recordWakeFailure}). A second failure of the same class rethrows
487
+ * too — the seat could not be marked, so a third attempt would resume the same dead run.
488
+ *
489
+ * THE ROUTE FUNCTION MUST BE RE-RUNNABLE, and both wrapped routes are. Everything they do before
490
+ * the divert is a read or an idempotent write: TTL-cached `conversations.info` / `users.info`
491
+ * probes, a deterministic external-policy verdict (its private notice posts only on a verdict that
492
+ * RETURNS rather than throws, so a retry never reaches it), a read-only spend gate, and
493
+ * `markEngaged`, which upserts. `recordWakeTail` and `touchDmSeat`/`touchSession` all run AFTER the
494
+ * divert, so a failed attempt records nothing to duplicate. The wake id is minted fresh, which is
495
+ * correct — the retry is a new delivery.
496
+ *
497
+ * Applied only to the WINDOW-bearing routes — a thread seat has no `channel_session` row to mark,
498
+ * so a retry there could only resume the same dead run.
499
+ *
500
+ * OPE-970: when the RETRY ALSO throws, the seat is still healed for its NEXT wake — but THIS
501
+ * member's message got no answer, and that fact must not collapse into an ordinary send-failure
502
+ * row indistinguishable from a transient blip. The retry's error is tagged
503
+ * ({@link markUndeliveredAfterHeal}) before it rethrows, so {@link recordWakeFailure} can give the
504
+ * row its own reason.
505
+ */
506
+ export async function deliverWithStrandRetry(deliver) {
507
+ try {
508
+ return await deliver();
509
+ }
510
+ catch (err) {
511
+ if (!isPrunedDeploymentWakeError(err))
512
+ throw err;
513
+ console.warn(`[open-everywhere:slack-front] wake hit a pruned deployment — seat marked for rotation, re-delivering once: ${String(err)}`);
514
+ try {
515
+ return await deliver();
516
+ }
517
+ catch (retryErr) {
518
+ markUndeliveredAfterHeal(retryErr);
519
+ throw retryErr;
520
+ }
521
+ }
522
+ }
523
+ export function recordWakeFailure(key, err) {
524
+ const channelId = key.split(":")[0] ?? key;
525
+ try {
526
+ enqueueLogRecord(buildRecord("error", "wake", "supervisor/thread wake delivery failed", { fields: { fifoKey: key, channelId }, err }));
527
+ void flushLogShip();
528
+ }
529
+ catch {
530
+ /* log-ship best-effort */
531
+ }
532
+ // Held on the route (OPE-1648): the FIFO promise this runs under is itself in `waitUntil`, but a
533
+ // `void`-ed write sits OUTSIDE that promise and so races the freeze after the 200 all the same.
534
+ void holdRouteWork((async () => {
535
+ try {
536
+ const p = resolvePartition({
537
+ channel: { kind: "slack" },
538
+ session: { auth: { current: { attributes: { team_id: "", channel_id: channelId }, principalId: "", principalType: "slack-user" } } },
539
+ });
540
+ // OPE-970: a retry that ALSO failed after the pruned-deployment heal gets its own reason —
541
+ // otherwise it is indistinguishable from any other send failure, and the only way to tell
542
+ // "the seat is fine now but this message was lost" from "a transient blip" was to read the
543
+ // raw message text and recognise the run-key sentence by eye.
544
+ const undeliveredAfterHeal = isUndeliveredAfterHeal(err);
545
+ await getOperationalStore().appendAudit({
546
+ ts: new Date().toISOString(),
547
+ org: p.org,
548
+ platform: p.platform,
549
+ workspace: p.workspace,
550
+ surface: p.surface,
551
+ action: "surface.wake_failed",
552
+ detail: {
553
+ fifoKey: key,
554
+ message: String(err instanceof Error ? err.message : err).slice(0, 500),
555
+ ...(undeliveredAfterHeal
556
+ ? { reason: "pruned-deployment-heal-retry-failed", remedy: "seat healed for its next wake; this message got no answer" }
557
+ : {}),
558
+ },
559
+ });
560
+ }
561
+ catch {
562
+ /* audit is best-effort — the log-ship record above is the durable signal */
563
+ }
564
+ })());
565
+ }
566
+ /**
567
+ * Build the front route handler. Buffers the body once, verifies the signature, then forwards
568
+ * or wakes per {@link classifyEnvelope}. `innerHandler` is slackChannel's own POST handler,
569
+ * invoked with a FRESH Request (re-readable body) + the same {@link RouteHandlerArgs}, so
570
+ * slackChannel re-verifies and dispatches exactly as if it owned the URL.
571
+ */
572
+ /**
573
+ * SINGLE-WINDOW diversion (channels/unified-window.ts): layer the cross-channel hop onto the
574
+ * supervisor deps. When routeSupervisorEvent minted a `window` for a wake (the domain's
575
+ * crossSurfaceWindow gates hold — the wakeId is already rendered into the envelope), deliver it
576
+ * through eve's cross-channel `to(...).send(...)` into the synthetic `unified` channel instead of the
577
+ * Slack send: the effective token becomes `unified:<raw>`, shared with every surface in the
578
+ * domain — ONE context window. The unified channel's `receive` registers the wakeId→origin map
579
+ * entry (durably, BEFORE the send) and re-checks the gates fail-closed. This wrapper lives HERE
580
+ * because only the route handler holds `args.to` (and slack-events.ts must not import
581
+ * unified.ts — unified.ts imports it). A window-less wake passes through untouched, so flag-off
582
+ * is byte-identical. A diversion failure PROPAGATES to the FIFO's loud catch (fail-closed: a
583
+ * wake whose reply map couldn't be written is not delivered into the shared window).
584
+ *
585
+ * THREAD-OWNERSHIP ingress routing (§S2): the divert goes through the INBOUND variant
586
+ * (unified.ts divertInboundToUnifiedWindow), which — only under delegation.oneshotAgent — consults the
587
+ * durable ownership index and dispatches a wake landing in an OWNED thread to the owning task/
588
+ * thread-seat session's token instead of the standing seat's. Flag-off, and on every unowned
589
+ * thread, the inbound variant is a byte-identical passthrough to divertToUnifiedWindow.
590
+ *
591
+ * OPE-971: the `!input.window` (NATIVE, flag-off) branch below is Slack's own un-windowed
592
+ * supervisor session — the send this domain takes when it never opted into windowing at all. That
593
+ * send was completely unwrapped: a pruned-deployment strand there stranded the seat silently,
594
+ * forever (see channels/stranded-seat.ts). `deps.wake`'s own registryKey for this branch IS
595
+ * `surfaceKey` (supervisorGrainFor returns `{unified:false, registryKey:surfaceKey}` flag-off — the
596
+ * exact row `liveSupervisorDeps`'s `getOrCreateSession` already rotates), so the heal is told that
597
+ * key directly rather than trying to parse it back out of the per-channel token.
598
+ */
599
+ export function withSingleWindowDiversion(deps, to, surfaceKey) {
600
+ return {
601
+ ...deps,
602
+ async wake(input) {
603
+ if (!input.window) {
604
+ try {
605
+ await deps.wake(input);
606
+ }
607
+ catch (err) {
608
+ await healPrunedDeploymentStrand({
609
+ token: input.token,
610
+ registryKey: surfaceKey,
611
+ surfaceKey,
612
+ workspace: surfaceKey.split("/")[1] ?? "unknown",
613
+ platform: "slack",
614
+ atIso: new Date().toISOString(),
615
+ err,
616
+ });
617
+ throw err;
618
+ }
619
+ return;
620
+ }
621
+ await divertInboundToUnifiedWindow(to, {
622
+ message: input.message,
623
+ // The seat wake's untrusted-wrapped side-channel context (the name-drop hydration,
624
+ // OPE-1725). Omitted on every other wake, so a context-less divert is byte-identical.
625
+ ...(input.context && input.context.length > 0 ? { context: input.context } : {}),
626
+ token: input.token,
627
+ window: input.window,
628
+ surfaceKey,
629
+ auth: input.auth,
630
+ });
631
+ },
632
+ };
633
+ }
634
+ // DM-PARTNER TRUST, front-route copy (OPE-351 stage 2). `dm_partner_trust` gates the DM's SHARED read
635
+ // layer (internal partner ⇒ `dm` trust, external ⇒ `public`), so the diverted DM's auth must carry it
636
+ // or every DM silently degrades to the external tier — a regression the native path would not have had.
637
+ // The native path resolves it in slack.ts (`enrichDmPartner`) off `ctx.slack.request`, which does not
638
+ // exist here (the front route holds no eve slack context, and slack.ts imports THIS module, so the
639
+ // helper cannot simply be shared without a cycle).
640
+ //
641
+ // OPE-466: the PROBE ITSELF now lives in slack-events.ts (`slackAuthorIsMember`), because the same
642
+ // question — is this person a full member of this workspace? — is what a CHANNEL author's wake tier
643
+ // needs, and two caches of one answer drift. This function is the DM leg's reading of that verdict,
644
+ // and it keeps the DM leg's own FAIL-CLOSED direction: an unresolvable probe leaves the attribute
645
+ // unset and the binding fails closed to the external tier, unchanged from before.
646
+ async function liveDmPartnerTrust(userId, teamId) {
647
+ const member = await slackAuthorIsMember(userId, teamId);
648
+ return member === undefined ? undefined : member ? "internal" : "external";
649
+ }
650
+ /** Reset the cached DM-partner trust (tests only). */
651
+ export function _resetDmTrustCache() {
652
+ _resetAuthorMemberCache();
653
+ }
654
+ // `wrapDmSeatFrom` (OPE-90) is DELETED at OPE-351 stage 2. It existed to remap the address of eve's
655
+ // NATIVE DM send onto a rotatable seat token — a bolt-on to a dispatch path a DM no longer takes. The
656
+ // DM is now diverted onto the `unified` channel before the forward ever happens (`scheduleDmWake`
657
+ // below), so the seat token is chosen at DISPATCH time by the same code every other standing seat
658
+ // uses, and there is no native send left to intercept. What it carried forward, and where each part
659
+ // lives now: the seat resolve → `resolveDmSeat` in `scheduleDmWake`; the fresh-generation tail
660
+ // prepended as `context` → the divert's own `context` argument; the off-critical-path wake touch →
661
+ // the same `waitUntil(touch…)` there. What it does NOT carry forward is its FAIL-OPEN fallback to the
662
+ // native send: there is nothing honest to fall back TO, since eve's native DM dispatch would open a
663
+ // session on an address the seat has no window row for. A DM whose seat cannot be resolved now fails
664
+ // LOUDLY into the FIFO's catch (audited via recordWakeFailure) instead of silently answering from a
665
+ // second, un-rotatable session — the same fail-closed posture every other divert has.
666
+ /**
667
+ * Does this `message` event DUPLICATE an `app_mention` Slack also fires, so the wake path must drop it?
668
+ *
669
+ * Slack sends TWO events for a new message that @mentions the bot: the `app_mention`, which the
670
+ * front's own `mention` branch owns, and a sibling carrying the same text — `message.channels` in a
671
+ * channel, `message.mpim` in a group DM. Acting on both is a double turn, so both the channel wake
672
+ * path and the group-DM leg drop the sibling through this one rule.
673
+ *
674
+ * 🔴 AN EDIT IS NEVER A SIBLING, AND SUPPRESSING ONE IS A LOST WAKE (OPE-1673). Slack delivers an
675
+ * edit ONLY as `message` with `subtype: "message_changed"`; no `app_mention` accompanies it, and
676
+ * `parseMessageEvent` reads the POST-EDIT text — so a person who edits a message in an engaged
677
+ * thread to add `<@bot>`, or who corrects the wording of a mention that is already there, matched
678
+ * this rule and was answered by nothing at all. There is no second event to prefer. The exemption is
679
+ * narrow by construction: an edit cannot reach the `app_mention` branch, because the front classifies
680
+ * the envelope on its event TYPE and a `message_changed` always arrives as a `message`.
681
+ *
682
+ * UNVERIFIED, and stated rather than assumed: Slack's docs do not say whether an edit that ADDS a
683
+ * mention also raises a fresh `app_mention`. If it does, the cost of this exemption is an occasional
684
+ * double turn — the same trade the next paragraph already takes, for the same reason.
685
+ *
686
+ * MATCH THE AUTHOR'S OWN TEXT, NEVER THE MERGED ONE (OPE-1374). `ev.text` carries forwarded and
687
+ * quoted content, so testing it would suppress a forward whose QUOTED message mentions the bot.
688
+ * UNVERIFIED for the same reason as above: whether Slack fires an `app_mention` for a mention that
689
+ * lives inside an attachment. If it does NOT — the shape we expect — testing the merged text loses
690
+ * the wake outright, because no sibling ever arrives to deliver it. If it DOES, the cost is a double
691
+ * turn. We take the double: an occasional double beats a drop, and a deaf agent on a forward is the
692
+ * failure OPE-1374 exists to prevent.
693
+ *
694
+ * Pure, so both the suppression and its exemption are unit-testable without Slack or a store. An
695
+ * unresolved bot user id suppresses nothing — there is no needle to match, and inventing one would
696
+ * drop every message.
697
+ */
698
+ export function mentionSiblingSuppressed(input) {
699
+ if (input.botUserId === undefined || input.botUserId.length === 0)
700
+ return false;
701
+ if (input.subtype === "message_changed")
702
+ return false;
703
+ return input.authoredText.includes(`<@${input.botUserId}>`);
704
+ }
705
+ export function buildFrontHandler(innerHandler, opts = {}) {
706
+ const dedupe = opts.dedupe ?? new EventDedupe();
707
+ const fifo = opts.fifo ?? new ThreadFifo(recordWakeFailure);
708
+ const getBotIdentity = opts.getBotIdentity ?? resolveBotIdentity;
709
+ const now = opts.now ?? (() => Date.now());
710
+ const signingSecret = () => opts.signingSecret ?? process.env.SLACK_SIGNING_SECRET;
711
+ const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
712
+ // OPE-589 leg D: reuse the taint-marking member probe (slack-events.ts `slackClickerIsGuest`,
713
+ // itself `slackAuthorIsMember` inverted). Same `users.info` call and cache the channel-taint leg
714
+ // already pays for, so a clicker who recently posted a message in the same workspace is often a
715
+ // free lookup, not a new one.
716
+ const handleApproval = opts.handleApprovalPayload ??
717
+ ((payload, deps) => liveHandleApprovalPayload(payload, { clickerIsGuest: slackClickerIsGuest, ...(deps?.waitUntil ? { waitUntil: deps.waitUntil } : {}) }));
718
+ const handleSetKeyClick = opts.handleSetKeyClick ?? liveHandleSetKeyClick;
719
+ const handleSetKeyViewSubmission = opts.handleSetKeyViewSubmission ?? liveHandleSetKeyViewSubmission;
720
+ const handleConnectClick = opts.handleConnectClick ?? liveHandleConnectClick;
721
+ const handleGrantCardClick = opts.handleGrantCardClick ?? liveHandleGrantCardClick;
722
+ const handleApprovalCardClick = opts.handleApprovalCardClick ?? liveHandleApprovalCardClick;
723
+ const ambientEnabledFor = opts.ambientEnabledFor ?? ((surfaceKey) => ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains()));
724
+ const seatedSurface = opts.seatedSurfaceFor ?? slackDomainUsesUnifiedWindow;
725
+ const mentionPolicyFor = opts.mentionPolicyFor ?? slackSingleWindowMentionPolicy;
726
+ const openDirectMessageChannel = opts.openDirectMessageChannel ?? liveOpenDirectMessageChannel;
727
+ const resolveDmSeat = opts.resolveDmSeat ?? ((org, dm, atIso) => liveGetOrCreateDmSeat(org, dm, atIso));
728
+ const touchDmSeatFn = opts.touchDmSeatFn ?? liveTouchDmSeat;
729
+ const dmPartnerTrustFor = opts.dmPartnerTrustFor ?? liveDmPartnerTrust;
730
+ const channelFactsFor = opts.channelFactsFor ?? slackChannelFactsFor;
731
+ const authorFactsFor = opts.authorFactsFor ?? ((internal, userId, teamId) => slackWakeAuthorFacts(internal, userId, teamId));
732
+ // THE one single-window mention decision, shared by BOTH mention legs (the `app_mention`
733
+ // native-forward below and its `message.channels` sibling on the wake path) so they stay
734
+ // exactly complementary — never a double turn (native + unified), never a gap (neither). True ⇔
735
+ // the channel has a STANDING SEAT and the policy's privacy floor holds (channel POSITIVELY
736
+ // confirmed internal, fail-closed). Any error ⇒ false ⇒ the native split.
737
+ // 🔴 THE CHATTINESS PRECONDITION IS GONE (OPE-1695). Requiring `ambientSupervisor` here is what
738
+ // made a mention in an addressed-only room open eve's native per-thread session — the fallback
739
+ // the ruling deletes. Its complement moved with it: `scheduleUnownedWake` now takes the seat road
740
+ // for every addressed wake, so suppressing the native leg can no longer leave the mention with
741
+ // nowhere to land.
742
+ const singleWindowOwnsMention = async (channelId, surfaceKey) => (await mentionPolicyFor(channelId, surfaceKey)).suppressNative;
743
+ // 🔴 THE WHOLE HANDLER RUNS INSIDE THE ROUTE HOLD (OPE-1648). Every door below answers its 200 at
744
+ // once and finishes its work afterwards — this route through `args.waitUntil`, eve's native
745
+ // handler through the same `args` on the forward — so a `void`-ed audit write anywhere under here
746
+ // races the serverless freeze that follows the response and loses the row.
747
+ //
748
+ // 🔴 EVERY `waitUntil` THE DELIVERY REGISTERS IS QUIESCED FIRST, and that is what makes the hold
749
+ // reach the wake routes. eve snapshots its background-task array the instant this handler
750
+ // resolves, so a `waitUntil` called later — from an engagement route running inside one of the
751
+ // FIFO promises below, long after the 200 — lands in a list nobody awaits. Every such route runs
752
+ // INSIDE a promise registered here while the handler was still going, so handing the platform a
753
+ // promise that outlives that route's own writes is enough, and nothing extra is registered: a
754
+ // delivery that queues no background work still hands the platform nothing at all.
755
+ return (req, args) => withSlackRouteHold(() => {
756
+ // `Object.create`, not a spread: the route args are eve's object, and a spread would keep only
757
+ // its OWN enumerable properties. Shadowing one method on a prototype-linked copy leaves
758
+ // everything else — `from`, `to`, `attachSession`, `params` — resolving to eve's own.
759
+ const held = Object.assign(Object.create(args), {
760
+ waitUntil: (p) => args.waitUntil(routeHoldQuiesced(p)),
761
+ });
762
+ return handleFrontRequest(req, held);
763
+ });
764
+ async function handleFrontRequest(req, args) {
765
+ const rawBody = await req.text();
766
+ if (!verifySlackSignature(rawBody, req.headers, signingSecret(), now())) {
767
+ return new Response("unauthorized", { status: 401 });
768
+ }
769
+ const decision = classifyEnvelope(rawBody, req.headers.get("content-type") ?? "");
770
+ /**
771
+ * Hand this request to eve's native `slackChannel`, with the OPE-1374 attachment boundary
772
+ * applied to what eve is given (OPE-1380).
773
+ *
774
+ * 🔴 EVERY NATIVE FORWARD GOES THROUGH HERE, not just the `@mention` one. A fence on the mention
775
+ * path alone would be no fence at all — the same reasoning `slackWakeSpendGate` states for its
776
+ * own placement: an `im` message forwards natively too, and eve merges its attachments the same
777
+ * way. The click and view_submission fall-throughs carry no `event`, so for them this is the
778
+ * identity below and their forward stays byte-identical.
779
+ *
780
+ * 🔴 THE RE-SIGN IS WHAT MAKES A REWRITE POSSIBLE. The inner handler re-verifies the Slack
781
+ * signature over the body it receives, so a changed body needs a signature over those bytes. The
782
+ * secret is the one this route just VERIFIED the inbound signature with, so a deployment where
783
+ * the two secrets disagree would have 401'd above rather than reaching here.
784
+ *
785
+ * FAIL-SOFT TO TODAY'S BEHAVIOUR. If the body carried nothing to strip (the common path), or the
786
+ * signature cannot be reminted (no secret, no timestamp header), the ORIGINAL bytes and headers
787
+ * are forwarded. Losing the fence on a body we cannot re-sign is recoverable; dropping the
788
+ * message on a signature the inner handler then refuses is not.
789
+ *
790
+ * 🔴 IT ALSO CARRIES THE ENTERPRISE RUNG ACROSS THE FORWARD (OPE-1559). `enterprise_id` lives on
791
+ * the envelope, and eve hands its handler the INNER event only, so the rung would be lost here
792
+ * otherwise — and a Grid deployment that declared only the enterprise would resolve every native
793
+ * mention as an undeclared room. The stow wraps the WHOLE forward, including the fenced-body
794
+ * branch, because both branches end in the same inner handler. See
795
+ * {@link withForwardedSlackEnvelope} for why the carrier is async-scoped and message-matched.
796
+ * EVERY native forward is wrapped for the same reason the attachment fence is: an `im` message
797
+ * forwards natively too, so a rung carried on the mention path alone would be half a rung.
798
+ */
799
+ const forwardNative = () => withForwardedSlackEnvelope(forwardedEnvelopeOf(rawBody), () => {
800
+ const fenced = stripUnfencedSlackAttachments(rawBody);
801
+ const ts = req.headers.get(SLACK_TIMESTAMP_HEADER);
802
+ const secret = signingSecret();
803
+ if (fenced === rawBody || !ts || !secret) {
804
+ return innerHandler(new Request(req.url, { method: "POST", headers: req.headers, body: rawBody }), args);
805
+ }
806
+ // A fresh Headers, because the inbound `content-length` describes the ORIGINAL bytes and the
807
+ // fenced body is shorter. Dropped rather than recomputed: `Request` sets it from the body.
808
+ const headers = new Headers(req.headers);
809
+ headers.set(SLACK_SIGNATURE_HEADER, slackSignature(secret, ts, fenced));
810
+ headers.delete("content-length");
811
+ console.log("[open-everywhere:slack-front] stripped an unfenced link-unfurl attachment before the native forward (OPE-1380)");
812
+ return innerHandler(new Request(req.url, { method: "POST", headers, body: fenced }), args);
813
+ });
814
+ // `liveDeps` types `send`'s auth as `unknown` on purpose (it doesn't import Eve's auth type);
815
+ // the cast bridges that gap — at runtime the payload/token/auth pass straight through. Hoisted
816
+ // because BOTH the single-window app_mention divert (mention branch) and the unowned-message
817
+ // wake path feed the same supervisor/name-drop dispatcher.
818
+ //
819
+ // eve 0.31 replaced the route's flat `send(payload, { continuationToken, … })` with
820
+ // `from(address).send(message, { … })` — the address is bound at `from()` time instead of
821
+ // riding in the options, and `context` moved out of the payload into the options. OE's own
822
+ // `WakeSend` (slack-events.ts) is UNCHANGED by design: it is an OE-authored adapter interface,
823
+ // not eve's, so the whole wake/name-drop/supervisor/join layer below stays byte-identical and
824
+ // the version-shaped difference is confined to this one adapter.
825
+ const send = ((input, opts) => args.from(opts.continuationToken).send(input.message, {
826
+ auth: opts.auth,
827
+ ...(opts.state === undefined ? {} : { state: opts.state }),
828
+ ...(input.context && input.context.length > 0 ? { context: input.context } : {}),
829
+ }));
830
+ // Two-layer idempotency (in-memory first, then the durable cross-instance store) shared by the
831
+ // wake path AND the single-window mention divert: a Slack RETRY (same event_id) must never
832
+ // double-wake. Returns false when the event was already handled (caller 200s and stops).
833
+ // Fail-open on an unresolvable partition — dedupe prevents a double reply, not a security hole.
834
+ /** The two-layer dedupe, keyed EXPLICITLY. Split out from {@link notYetSeen} for the shortcut
835
+ * branch (OPE-1376), whose idempotency key is Slack's per-click `trigger_id` rather than an
836
+ * Events API `event_id` — see `shortcutWakeKey` for why the message's own ts is wrong there. */
837
+ const notYetSeenKey = async (key, ev) => {
838
+ if (dedupe.seen(key, now()))
839
+ return false;
840
+ dedupe.mark(key, now());
841
+ try {
842
+ const org = resolvePartition(bindableFor(ev)).org;
843
+ if (!(await markEventSeen(org, key, new Date(now()).toISOString())))
844
+ return false;
845
+ }
846
+ catch {
847
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
848
+ }
849
+ return true;
850
+ };
851
+ /** The Events API form: key one `event_id` (falling back to `channel:ts`). Every existing wake
852
+ * route reads this one; only the shortcut branch keys itself. */
853
+ const notYetSeen = (env, ev) => notYetSeenKey(wakeKey(env, ev.channelId, ev.ts), ev);
854
+ // UNOWNED message dispatch (top-level, or in-thread but not engaged): today's default is the
855
+ // name-drop path (per-domain opt-in, fail-closed). When the channel's domain opts into
856
+ // ambientSupervisor, the SAME unowned traffic routes to the channel-supervisor session instead
857
+ // (slack-events.ts routeSupervisorEvent) — the "always-on teammate" leg. `directMention` is the
858
+ // SINGLE-WINDOW addressed-mention divert (an app_mention event routed here from the mention
859
+ // branch): it forces the supervisor path (the only road into the unified window) and renders an
860
+ // addressed `direct-mention` wake. The supervisor wake serializes on its OWN FIFO key (the
861
+ // supervisor token), never shared with a thread FIFO. Fail-closed: any partition/config error,
862
+ // or the flag off, falls through to the UNCHANGED name-drop dispatch — so ambientSupervisor-off
863
+ // is byte-identical to today. Parametrised on `ev` so both the app_mention divert and a plain
864
+ // message.channels wake feed one dispatcher.
865
+ // `onWake` runs SYNCHRONOUSLY, once, immediately before a wake is scheduled — and never on the
866
+ // addressed-drop path below. The shortcut route (OPE-1442) uses it to seed the attachment
867
+ // registry only for a click that actually wakes something; every other caller omits it.
868
+ const scheduleUnownedWake = (ev, label, directMention = false, onWake, seatEngaged = false) => {
869
+ let ambient = false;
870
+ let surfaceKey;
871
+ try {
872
+ surfaceKey = resolvePartition(bindableFor(ev)).surfaceKey;
873
+ ambient = ambientEnabledFor(surfaceKey);
874
+ }
875
+ catch {
876
+ ambient = false; // unresolvable partition / config load → today's name-drop path
877
+ }
878
+ // 🔴 AN ADDRESSED WAKE TAKES THE SEAT ROAD, WHATEVER THE CHATTINESS FLAG SAYS (OPE-1695). The
879
+ // drop that stood here refused an addressed wake on an ambient-off channel, because the seat
880
+ // itself only existed where ambient was on — so a mention and a channel-message shortcut both
881
+ // died there. Seats are universal now: the flag decides whether UNADDRESSED talk wakes the
882
+ // seat, and addressing decides nothing about the session's shape. A mention in an
883
+ // addressed-only room therefore lands in that room's own seat, with the earlier exchange in
884
+ // context, instead of opening eve's native per-thread session (the fallback this ruling
885
+ // deletes) or being dropped.
886
+ // 🔴 THE DROP SURVIVES WHERE THERE IS NO SEAT, AND THAT KEEPS FLAG-OFF BYTE-IDENTICAL. With the
887
+ // standing-seat capability unarmed there is no seat road at all, so an addressed wake that fell
888
+ // through here would take the NAME-DROP path — a silently mis-judged wake, which is exactly what
889
+ // the old drop existed to prevent. The mention route never reaches it (its own policy leaves the
890
+ // native leg in charge when nothing windows); the SHORTCUT route does, and on such a deployment
891
+ // it is dropped loudly, exactly as before.
892
+ // An unresolvable partition leaves no surface key, so there is no seat to ask about: that
893
+ // reads as UNSEATED, which keeps the drop — the fail-closed direction the old branch had.
894
+ const seated = surfaceKey !== undefined && seatedSurface(surfaceKey);
895
+ if (directMention && !ambient && !seated) {
896
+ console.warn(`[open-everywhere:slack-front] ${label} ${ev.channelId}/${ev.ts}: addressed wake with no standing seat and ambient off — dropped (no road to a seat)`);
897
+ return;
898
+ }
899
+ // 🔴 `seatEngaged` IS NOT `directMention`, and the distinction is load-bearing. An ENGAGED
900
+ // thread's follow-up must reach the seat that is holding the conversation even in an
901
+ // addressed-only room — otherwise the seat answers a mention once and goes deaf on the very
902
+ // thread it opened. But it must NOT be treated as an address: `directMention` also lifts the
903
+ // app_mention/channel_type disqualifiers AND bypasses the stood-down suppression
904
+ // (slack-events.ts routeSupervisorEvent, `ev.threadTs && !directMention && engRow?.stopped`),
905
+ // so passing it here would make `stand_down` stop working in threads. It only says "there is a
906
+ // seat road for this message", which is what the chattiness flag would otherwise have said.
907
+ const addressedOrAmbient = directMention || ambient || seatEngaged;
908
+ onWake?.();
909
+ if (addressedOrAmbient) {
910
+ // Generation-INDEPENDENT FIFO key (T1.5): the FIFO's job is to serialize supervisor
911
+ // wakes for this CHANNEL, which must hold ACROSS a rotation boundary too — keying on
912
+ // the generation-bearing token (the old hardcoded gen-1 assumption) would split one
913
+ // channel's serialized queue in two the instant a rotation minted a new generation.
914
+ // supervisorFifoKey needs no store read either, so it stays a stable, synchronous key
915
+ // on the hot path exactly like the token-based key did.
916
+ const supKey = supervisorFifoKey(ev.channelId);
917
+ args.waitUntil(fifo.enqueue(supKey, () => deliverWithStrandRetry(() => routeSupervisorEvent(ev, withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey), directMention ? { directMention: true } : undefined).then(logOutcome(`${label}-supervisor`, ev.channelId, ev.ts)))));
918
+ return;
919
+ }
920
+ // 🔴 A NAME-DROP WAKES THE ROOM'S STANDING SEAT (OPE-1725). `routeNameDropEvent` stays the
921
+ // JUDGE — the name match, the domain's `nameDropWake` opt-in, the internal confirmation, the
922
+ // wake arm — and on a SEATED surface its `seatRoute` hands the judged wake to that seat instead
923
+ // of opening eve's native per-thread session. Addressing decides what the seat is owed; it
924
+ // never decides the shape of the session, so the same seat an @mention reaches answers a
925
+ // name-drop, with the room's earlier exchange already in its context.
926
+ // UNSEATED ⇒ no `seatRoute`, and the native per-thread wake is byte-identical to today. That
927
+ // arm is the whole pre-seat deployment, so the fallback is not a corner case.
928
+ //
929
+ // 🔴 THE JUDGE RUNS OUTSIDE THE SEAT'S FIFO, and only a message it ADMITS enters one. Putting
930
+ // the judge inside would make every unaddressed message in a seated room occupy the channel's
931
+ // serialized queue for the length of a decision that almost always declines — a real cost on a
932
+ // busy room, and it would hold a genuine seat wake behind it. `fifo.enqueue` resolves void and
933
+ // SWALLOWS a failure, so the outcome is captured in the closure and the default stays the
934
+ // honest "did not wake" for a delivery that threw.
935
+ // 🔴 THE HYDRATOR RIDES ACROSS THE HAND-OFF (OPE-1725). The judge fetches the room's
936
+ // surrounding conversation for the wake it admits, and the SEAT wake must carry that same
937
+ // block — withheld by the same transform — or a seated deployment quietly stops hydrating
938
+ // name-drops at all. It is passed as a thunk, so the seat road pays for the history page only
939
+ // after its own tenant fence, spend gate and external-participant policy admit the wake.
940
+ const seatRoute = seated
941
+ ? async (hydrateContext) => {
942
+ let outcome = { deliver: false, reason: "seat wake delivery failed", woke: false };
943
+ await fifo.enqueue(supervisorFifoKey(ev.channelId), async () => {
944
+ outcome = await deliverWithStrandRetry(() => routeSupervisorEvent(ev, withSingleWindowDiversion(liveSupervisorDeps(send), args.to, surfaceKey), {
945
+ nameDrop: true,
946
+ nameDropContext: hydrateContext,
947
+ }));
948
+ });
949
+ return outcome;
950
+ }
951
+ : undefined;
952
+ args.waitUntil(routeNameDropEvent(ev, { ...liveNameDropDeps(send), ...(seatRoute ? { seatRoute } : {}) }).then(logOutcome(seatRoute ? `${label}-name-drop-seat` : label, ev.channelId, ev.ts)));
953
+ };
954
+ /**
955
+ * THE DM WAKE (OPE-351 stage 2; group DMs at stage 4) — a `message.im` or `message.mpim` diverted
956
+ * into its own first-class standing window, the DM analogue of the single-window app_mention
957
+ * divert above. `dmKind` names which family member it is; everything the two differ in is
958
+ * called out below, and nothing else does.
959
+ *
960
+ * 🔴 IT DELIBERATELY BYPASSES routeSupervisorEvent, engagementTierFor AND THE NAME-DROP JUDGE.
961
+ * Every one of those exists to answer "was the agent addressed, and is this worth replying to?"
962
+ * — a question a 1:1 DM has already answered. A DM IS ALWAYS ANSWERED; routing it through a
963
+ * silence-biased judge would let the agent ignore someone talking to it directly, which is the
964
+ * one place it must never be quiet. So this path renders an ADDRESSED wake (`slackDmWake`,
965
+ * `addressing:"direct-mention"`) and dispatches it, full stop. The spend gate is NOT bypassed:
966
+ * `divertToUnifiedWindow`'s own attended-arrival gate (windowSpendGate) runs on this wake exactly
967
+ * as it does on a mention, which is what replaces the native door's gate.
968
+ *
969
+ * A GROUP DM WAKES ON THE SAME UNCONDITIONAL RULE, AND THAT IS A REAL WIDENING — its previous
970
+ * route (`shouldNameDropWake`) refused every non-mention message on channel_type, so the room woke
971
+ * only on an @mention. The justification is not that every message in a group DM is addressed to
972
+ * the agent (it plainly is not), but that a closed room the agent was added to person-by-person is
973
+ * a conversation it is IN, and the judgement of whether to speak belongs to the model, which has
974
+ * the text, rather than to a front-door gate keyed on a channel type. What carries that is the
975
+ * wake's own silence escape plus the GROUP opening of the `[direct]` preamble
976
+ * (channels/normalized-wake.ts), which says the room holds other people and tells the seat to stay
977
+ * out of what is not asked of it. The COST of the widening lands on the spend gate above, not on
978
+ * a judge: a chatty group DM is a budget question, and it is already answered by one.
979
+ *
980
+ * THE ENVELOPE IS MINIMAL, ON PURPOSE — no backscroll. The native DM path deliberately skipped
981
+ * `loadConversationContext` (slack.ts: "DMs are skipped — the 1:1 history IS the conversation and
982
+ * the durable session already carries it"), and that reasoning survives the re-seat unchanged: the
983
+ * seat is one long-lived session over the whole DM, so injecting history would duplicate what the
984
+ * session already holds. The one thing a FRESH generation carries is the recorded tail of the
985
+ * generation it replaced (OPE-794) — the case that argument does not cover, since a rotation is
986
+ * exactly when the session stops holding it.
987
+ *
988
+ * A STRICT IMPROVEMENT IN FENCING: eve's native DM dispatch delivered the human's text as the raw
989
+ * turn body. Going through the shared wake renderer means a DM body is now `wrapUntrusted`-fenced
990
+ * like every other surface's — a DM was the last inbound text that was not.
991
+ *
992
+ * FAIL-CLOSED, unlike the OPE-90 remap it replaces: no seat, no partition or no dispatch means the
993
+ * wake FAILS into the FIFO's loud catch (audited by recordWakeFailure) rather than falling back to
994
+ * a native send, which would answer the human from a second session with no window row behind it.
995
+ */
996
+ const scheduleDmWake = async (ev, dmKind,
997
+ /** The DM door admitted this bot author as the deployment's named e2e driver (OPE-1023).
998
+ * Defaults FALSE, so every existing call site is unchanged. */
999
+ driverAdmitted = false) => {
1000
+ // 🔴 THE TENANT FENCE, AND THIS IS ITS CHOKEPOINT FOR THE WHOLE DM FAMILY (OPE-1644). Before
1001
+ // this line `slack-front.ts` consulted the fence NOWHERE, while the mention door, the four
1002
+ // dispatching engagement routes, the channel-join door and the `/oe` slash door all did — so a
1003
+ // DM from a workspace this deployment never declared woke the DM seat. That seat is the
1004
+ // LARGEST grant the deployment makes: it holds a window token, delegation and memory, and a
1005
+ // DM is exactly where a colliding `U…` id resolves somebody's PERSONAL credentials.
1006
+ //
1007
+ // EVERY road into a DM window passes through here — the `message.im`/`message.mpim` branch,
1008
+ // the DM-shaped shortcut and the group-DM mention divert — so the fence is placed here rather
1009
+ // than at each of the three, and no road can be added that skips it. The two callers that CAN
1010
+ // decide earlier do (the DM branch and the shortcut branch below), because a refusal there
1011
+ // also spares the deployment a foreign workspace's attachment fetch and dedupe write; this
1012
+ // check is what makes those an optimisation rather than the fence itself.
1013
+ //
1014
+ // SILENT, like every other door: nothing is posted back into the DM. We cannot safely identify
1015
+ // anybody in an unadmitted workspace, and a foreign tenant learning it reached a live
1016
+ // deployment is more than it needs to know. The audit row and the operator log line are the
1017
+ // whole signal.
1018
+ if (slackFrontTenantFenceDeclined(ev, `dm-${dmKind}`, "scheduleDmWake"))
1019
+ return;
1020
+ const p = resolvePartition(bindableFor(ev));
1021
+ const atIso = new Date(now()).toISOString();
1022
+ const isGroup = dmKind === "mpim";
1023
+ // 🔴 WHERE THE SURFACE'S TRUST COMES FROM, and why the two families differ.
1024
+ // • 1:1 — the DM PARTNER, via users.info. It rides BOTH the delivery auth (`dm_partner_trust`
1025
+ // gates the DM's shared read layer) and the envelope's trust tier, so one lookup answers
1026
+ // both. Fail-closed to `external`/`unauth`.
1027
+ // • GROUP — there is no partner. `dm_partner_trust` is a singular fact about "the other person
1028
+ // in this DM", a question an mpim does not have an answer to, and `bindSlack` ignores the
1029
+ // attribute for `mode:"mpim"` anyway (its trust comes from `slackTrust`). Stamping one would
1030
+ // be inventing a fact. What stands in for the WAKE tier is the surface's own confirmed-
1031
+ // internal verdict — the same substitution routeSupervisorEvent makes for a channel — read
1032
+ // from the TTL-cached conversations.info the mention leg already needs. Fail-closed: an
1033
+ // unresolvable lookup (a workspace without `mpim:read`) leaves every author `unauth`, which
1034
+ // renders the reference-only system note rather than dropping the wake. The group's AUTHOR
1035
+ // is a separate probe, resolved just below — the room says nothing about who wrote here.
1036
+ const partnerTrust = !isGroup && ev.userId ? await dmPartnerTrustFor(ev.userId, ev.teamId) : undefined;
1037
+ const roomFacts = isGroup ? await channelFactsFor(ev.channelId) : undefined;
1038
+ const surfaceInternal = isGroup ? roomFacts?.internal === true : partnerTrust === "internal";
1039
+ // ── THE AUTHOR LEG (OPE-1486, Tommy's 2026-09-07 ruling) ────────────────────────────────────
1040
+ //
1041
+ // 🔴 A GROUP DM IS A ROOM, SO ITS AUTHOR IS PROBED LIKE A CHANNEL'S. `partnerTrust` answers a
1042
+ // 1:1's author (`dmPartnerTrustFor` probes `ev.userId` — the writer, not some third party), so
1043
+ // the 1:1 leg has always carried a real refutation. An mpim resolved none at all, which left
1044
+ // `oe_chat_nonmember` ABSENT on a turn a guest's own bytes opened. That is an INGESTION-grain
1045
+ // hole under the 2026-08-13 ruling: taint keys on whose words entered the context.
1046
+ //
1047
+ // The probe is `slackWakeAuthorFacts` — the SAME `users.info` verdict every channel route
1048
+ // reads, TTL-cached, so a person who wrote in this workspace recently costs no new call. It
1049
+ // fails OPEN: an unresolvable probe refutes nobody.
1050
+ //
1051
+ // 🔴 PRESENCE MARKS NOTHING. This asks who WROTE the trigger, never who sits in the room. No
1052
+ // roster is read here and none should be: a guest who has not spoken has put no bytes in the
1053
+ // context, and marking the turn for them would refuse the member plane on a room's occupancy.
1054
+ //
1055
+ // 🔴 THE WAKE TIER IS DELIBERATELY NOT NARROWED, AND THE FENCE IS CARRIED SEPARATELY. This is
1056
+ // the explicit decision the ticket asked for rather than an inherited one, and the two halves
1057
+ // of what the tier controls are what make it safe:
1058
+ // • the tier decides the RENDER — a `peer`/`principal` body is delivered unfenced, and only
1059
+ // `unauth` adds the reference-only system note. It does NOT decide whether the wake fires:
1060
+ // an mpim whose room cannot be confirmed already wakes at `unauth` rather than dropping;
1061
+ // • so the refutation rides to the render on its own field (`triggerRefuted` →
1062
+ // `WakeMessageInput.refuted`), which is a ONE-WAY FENCE BACKSTOP: it fences a refuted
1063
+ // author's body without touching the tier or the note.
1064
+ // What that buys, against folding the refutation into `surfaceInternal` as a channel does: a
1065
+ // guest the operator's own people invited into an addressed room still gets the addressed
1066
+ // envelope and an answer, while their bytes still reach the model INSIDE the fence and the
1067
+ // member plane still refuses a personal credential on the turn. Narrowing the tier as well
1068
+ // would add only the demotion of the envelope itself, which no part of this ruling asks for.
1069
+ const authorRefuted = isGroup ? (await authorFactsFor(surfaceInternal, ev.userId, ev.teamId)).refuted : partnerTrust === "external";
1070
+ const wakeTrust = resolveWakeTrust({
1071
+ // 🔴 THE ADMITTED DRIVER IS NOT A BOT AUTHOR HERE (OPE-1023) — the same expression the
1072
+ // engagement and mention doors already use (`Boolean(ev.botId) && !isAllowedSlackDriver(…)`),
1073
+ // applied to the DM door's own narrower lever. Left as a plain `Boolean(ev.botId)`, an
1074
+ // admitted driver would wake at the `relay` floor and the DM legs would exercise a trust tier
1075
+ // no human DM ever produces — which is not the real front door the carve-out exists to open.
1076
+ //
1077
+ // NO TRUST IS INVENTED. The driver posts through a human `xoxp` token, so `ev.userId` is a
1078
+ // REAL member id and the tier below is resolved from that human by the ordinary rules (an
1079
+ // unvouched surface still lands `unauth`, the admin roster still decides `principal`). What
1080
+ // the flag removes is only the machine floor that Slack's `bot_id` stamp would otherwise
1081
+ // impose on a message a person actually wrote.
1082
+ authorIsBot: Boolean(ev.botId) && !driverAdmitted,
1083
+ authorId: ev.userId,
1084
+ surfaceInternal,
1085
+ channelKey: p.surfaceKey,
1086
+ });
1087
+ // ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain) ──────────────────────────────────
1088
+ //
1089
+ // A DM is ALWAYS addressed, so an excluded sender here is owed BEHAVIOUR 1: the private notice
1090
+ // and nothing else. The notice posts back into this very DM — it is already private, so there
1091
+ // is no `conversations.open` to make and no room to disclose anything to.
1092
+ //
1093
+ // 🔴 IT SITS ABOVE `resolveDmSeat`, WHICH IS WHY THE TRUST BLOCK MOVED UP. A suppressed DM must
1094
+ // mint no seat, rotate no generation and consume no tail. The probe above is the same
1095
+ // one the seat path needed anyway, so the reorder costs nothing.
1096
+ //
1097
+ // A DM contributes NO ROOM LEG — on a DM the trust plane asks about the PARTNER, not about the
1098
+ // room — so only the AUTHOR grain can suppress here.
1099
+ //
1100
+ // 🔴 A GROUP DM IS SUPPRESSIBLE ON THE AUTHOR GRAIN TOO (OPE-1486). It was not before, because
1101
+ // an mpim resolved no refutation at all — the limit the old note here recorded. The author leg
1102
+ // above now resolves one from the same probe a channel uses, so an operator who set
1103
+ // `externalPolicy.authorGrain: "withhold"` gets that rule in a group DM as well. Nothing moves
1104
+ // on the SUPPRESSION PATH for a default deployment: `withholdsBody` is inert under the shipped
1105
+ // `taint` grain, so only a deployment that explicitly excluded refuted authors is suppressed
1106
+ // here — which is the rule it asked for. That is a claim about SUPPRESSION alone: on `taint`
1107
+ // the same refutation still newly stamps `oe_chat_nonmember` and still newly fences the body,
1108
+ // which is the fix. The room leg stays off for the whole DM family by the note above: on a DM
1109
+ // the trust plane asks about the PARTNER, not about the room.
1110
+ {
1111
+ // `authorRefuted` is already resolved above, from ONE probe per family — the 1:1's partner
1112
+ // lookup or the group's author lookup — so this gate makes no second call.
1113
+ const verdict = await applySlackExternalPolicy(ev, p, { trust: wakeTrust, refuted: authorRefuted }, "scheduleDmWake");
1114
+ if (verdict !== "proceed") {
1115
+ console.log(`[open-everywhere:slack-front] dm ${p.surfaceKey}: suppressed — externalPolicy verdict "${verdict}"`);
1116
+ return;
1117
+ }
1118
+ }
1119
+ // `channelId` is what the ROTATION distiller fetches history with (always supplied);
1120
+ // `legacyChannelId` is the one-time adopt-from-OPE-90 probe and is 1:1-ONLY. OPE-90 never minted
1121
+ // an `oe-dm-seat::<id>` row for a group DM, so probing for one could only ever miss — and a miss
1122
+ // that cannot be distinguished from a store outage is worse than no probe, because the OPE-346
1123
+ // note-loss audit row exists precisely to tell those two apart.
1124
+ const seat = await resolveDmSeat(p.org, { surfaceKey: p.surfaceKey, channelId: ev.channelId, ...(isGroup ? {} : { legacyChannelId: ev.channelId }) }, atIso);
1125
+ const wakeId = mintWakeId();
1126
+ // buildSlackAuth's DM attributes VERBATIM — including the rootDmGrainAttrs `oe_grain:"standing"`
1127
+ // stamp, which is what narrows a DM-family root's hands (window-tool-gating §D-DM, which already
1128
+ // covered mpim). `isPrivate:true` holds for both: a 1:1 is unambiguous, and a group DM has no
1129
+ // public form. `channelType` is the dmKind verbatim, so `slackMode` binds `dm` / `mpim` and every
1130
+ // mode-keyed carve-out (the lean-seat identity trio, the memory route, the skills refusal) sees
1131
+ // the SAME mode it saw on the native token.
1132
+ //
1133
+ // OPE-466 CARRIAGE: a DM/group-DM wake ingests exactly one message, so the join runs over the
1134
+ // trigger's own tier. The 1:1 leg already resolves `unauth` for a guest or Connect stranger
1135
+ // (the partner probe above IS the membership probe); the group leg keeps the ROOM's tier and
1136
+ // carries its author's refutation separately — see the author-leg note above for why the two
1137
+ // are not folded together on an mpim.
1138
+ const auth = stampNonMemberChatAuth(buildSlackAuth({
1139
+ userId: ev.userId ?? "",
1140
+ teamId: ev.teamId,
1141
+ // The Grid rung (OPE-1508), the same conditional spread `authForEvent` uses: a group DM
1142
+ // in an Enterprise Grid carries the enterprise the operator's declaration is written at,
1143
+ // and a non-Grid event stamps nothing.
1144
+ ...(ev.enterpriseId === undefined ? {} : { enterpriseId: ev.enterpriseId }),
1145
+ channelId: ev.channelId,
1146
+ threadTs: ev.threadTs,
1147
+ channelType: dmKind,
1148
+ isPrivate: true,
1149
+ ...(partnerTrust ? { dmPartnerTrust: partnerTrust } : {}),
1150
+ // 🔴 THE GROUP DM CARRIES THE PLATFORM'S OWN SHARING VERDICT (OPE-1486), which is what a
1151
+ // channel wake has always stamped (`authForEvent`) and an mpim wake never did. `bindSlack`
1152
+ // reads the pair through `slackTrust`, so this is the binding's only route to the fact
1153
+ // that a group DM spans another organisation — and `binding.trust` is the DEMOTION half of
1154
+ // the member plane's G4b vouch. Without it a Slack Connect group DM was vouched internal
1155
+ // under a workspace-wide declaration, because `slackTrust` graded every mpim `internal`.
1156
+ //
1157
+ // POSITIVE-ONLY, and both halves are spread conditionally for it: a workspace without
1158
+ // `mpim:read` resolves neither fact, stamps neither attribute, and keeps today's verdict.
1159
+ // Absence of an answer must never demote a room — only the platform SAYING so does.
1160
+ ...(roomFacts?.extShared === undefined ? {} : { isExternal: roomFacts.extShared }),
1161
+ ...(roomFacts?.trustChecked === undefined ? {} : { trustChecked: roomFacts.trustChecked }),
1162
+ }),
1163
+ // OPE-489: the author leg reads the POSITIVE refutation. On a 1:1 that is exactly the
1164
+ // partner probe's `external` verdict — the same users.info answer, so no second lookup.
1165
+ // OPE-1486: a GROUP DM resolves the same refutation from the same probe a channel's author
1166
+ // leg uses, so a guest or Connect stranger who WRITES here marks the turn. The ROOM leg is
1167
+ // still off for the whole DM family — `dm: true` puts it out of scope for the `public` tier,
1168
+ // because on a DM `trust` asks about the PARTNER, not about the room.
1169
+ [{ trust: wakeTrust, refuted: authorRefuted }], { dm: true });
1170
+ // OPE-794 — a fresh DM generation opens with the OUTGOING generation's RECORDED TAIL as its
1171
+ // backscroll, in place of the deleted handoff note. The DM envelope is otherwise still minimal
1172
+ // by design (the seat is one long-lived session over the whole DM, so replaying history a live
1173
+ // session already holds would duplicate it) — the tail is exactly the case that reasoning does
1174
+ // not cover, because a rotation is when the session stops holding it.
1175
+ const message = renderWakeMessage(ev, () => atIso, {
1176
+ dm: true,
1177
+ trust: wakeTrust,
1178
+ // The FENCE BACKSTOP for the whole DM family (OPE-1486) — the SAME refutation the taint stamp
1179
+ // and the policy gate read, so the three cannot disagree about one author. On a 1:1 it is a
1180
+ // no-op in practice (a refuted partner is already `unauth`, and `unauth` is already fenced);
1181
+ // on a GROUP DM it is what fences a refuted author whose tier the room keeps at member grade.
1182
+ triggerRefuted: authorRefuted || undefined,
1183
+ wakeId,
1184
+ ...(seat.freshGeneration && seat.tail.length > 0 ? { backscroll: seat.tail } : {}),
1185
+ });
1186
+ // 🔴 NO `replyAnchorTs` ON A TOP-LEVEL DM — the reply stays FLAT in the conversation. The
1187
+ // CHANNEL divert DOES set one (slack-events.ts), because threading an answer under its trigger
1188
+ // keeps a busy shared channel tidy; the slack post arm reads `threadTs ?? replyAnchorTs`
1189
+ // as its thread target, so setting it here would thread EVERY DM answer under the message that
1190
+ // asked — shattering a 1:1 conversation into a pile of one-reply threads. That is also not what
1191
+ // the native path did: eve replied to the wake's own `thread_ts` (OPE-98's per-wake anchor), which
1192
+ // is absent on a top-level DM message, so the answer posted flat. Behaviour preserved deliberately.
1193
+ // A DM message that genuinely IS in a thread still carries its threadTs and is answered in it.
1194
+ //
1195
+ // 🔴 A GROUP DM IS FLAT TOO — and here that DEPARTS from the native behaviour rather than
1196
+ // preserving it, so the reasoning has to stand on its own. Today an mpim @mention is answered by
1197
+ // eve's native path, which anchors on the wake's own `thread_ts` and therefore THREADS under the
1198
+ // mention. Keeping that would put two reply geometries in one room served by one seat: the
1199
+ // mention-carried wake threaded, every other message in the same conversation flat — the
1200
+ // placement flipping on whether the human happened to type `<@bot>`. Between "match what mpim did
1201
+ // yesterday" and "one seat, one room, one placement", the second wins: the seat is a single
1202
+ // long-lived session over the WHOLE group DM (the same property that makes a 1:1 flat), and a
1203
+ // Slack group DM is capped at nine people, so it has none of the tidiness problem threading
1204
+ // solves in a busy channel. A genuinely threaded mpim message still carries its threadTs and is
1205
+ // still answered inside that thread, exactly as in a 1:1.
1206
+ const origin = { platform: "slack", channelId: ev.channelId, ...(ev.threadTs ? { threadTs: ev.threadTs } : {}) };
1207
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719), on the DM leg too — and the reason is the
1208
+ // `threadTs` two comment blocks above. A Slack DM CAN hold a thread, so a DM thread can be
1209
+ // owned: the DM seat runs on a standing window token, `spawn_thread_session` mounts on it, and
1210
+ // a seat spawned inside a DM thread records a real ownership row. On the raw divert that row
1211
+ // was never read, so the seat was orphaned until its TTL — the exact defect this ticket closes,
1212
+ // on the one surface whose DM carries a thread signal at all. Discord's and Teams' DM legs stay
1213
+ // on the raw divert because THEIR DM origins carry none, not because a DM is special.
1214
+ // A top-level DM message has no `threadTs`, so it is not thread-scoped and goes standing with
1215
+ // no store read — flat DM behaviour is byte-unchanged.
1216
+ await divertInboundToUnifiedWindow(args.to, {
1217
+ message,
1218
+ token: seat.token,
1219
+ // `vendorTs` (OPE-1716): the trigger's OWN ts, which on Slack is its send time — the first
1220
+ // latency anchor, recorded on the ingress row and acted on by nothing.
1221
+ window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(slackVendorEventTime(ev.ts)) },
1222
+ surfaceKey: p.surfaceKey,
1223
+ auth,
1224
+ });
1225
+ // Record the wake off the critical path (best-effort — a touch failure never affects the reply).
1226
+ args.waitUntil(touchDmSeatFn(p.org, p.surfaceKey, atIso).catch(() => { }));
1227
+ // OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
1228
+ // the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
1229
+ args.waitUntil(recordWakeTail(p.org, dmWindowRegistryKey(p.surfaceKey), seat.token, {
1230
+ kind: "wake",
1231
+ at: atIso,
1232
+ from: "human",
1233
+ trust: wakeTrust,
1234
+ // THE AUTHOR GRAIN (OPE-1747) — the same `authorRefuted` the DM's own taint join and
1235
+ // external-policy verdict above read, through the one core road.
1236
+ ...tailAuthorGrain({ trust: wakeTrust, refuted: authorRefuted }),
1237
+ author: ev.userId ?? "unknown",
1238
+ ...(ev.userId ? { authorId: ev.userId } : {}),
1239
+ ...(ev.ts ? { id: ev.ts } : {}),
1240
+ platform: "slack",
1241
+ container: p.surfaceKey,
1242
+ text: ev.text ?? "",
1243
+ }));
1244
+ };
1245
+ // THE IN-MEMORY ATTACHMENT REGISTRY, seeded from an event whose files the front door has already
1246
+ // ADMITTED. Every Events API path gets this inside `parseMessageEvent`, beside its own
1247
+ // `parseSlackFiles` call. The SHORTCUT route cannot (OPE-1442): its files must clear the
1248
+ // readability gate and the wake gates first, and both of those live here rather than in the
1249
+ // parser, so that route seeds through this helper instead. Image-only, matching what
1250
+ // `describe_image` resolves; the durable half for another process is `persistAttachments` below.
1251
+ const registerAttachments = (ev) => {
1252
+ for (const a of ev.attachments ?? []) {
1253
+ if (a.kind === "image" && a.id && a.url) {
1254
+ registerSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, ...(ev.teamId ? { teamId: ev.teamId } : {}) });
1255
+ }
1256
+ }
1257
+ };
1258
+ // DURABLE ATTACHMENT REGISTRY (task #107): mirror this message's uploads into the durable
1259
+ // operational store so a DELEGATED worker — a one-shot task in a SEPARATE process from this
1260
+ // seat — can resolve a file id the seat saw (the in-memory registry is process-local). Trusted
1261
+ // provenance: the url_private is the EVENT-supplied string (never a model string), org resolved
1262
+ // from the same event. Off the hot path (waitUntil); fail-open on an unresolvable partition (the
1263
+ // in-memory registry still serves the same-process seat). Persists NON-IMAGE files too, which the
1264
+ // image-only in-memory registry never records — the raw-bytes read path (read_file_upload) needs them.
1265
+ const persistAttachments = (ev) => {
1266
+ const atts = ev.attachments;
1267
+ if (!atts || atts.length === 0)
1268
+ return;
1269
+ let org;
1270
+ try {
1271
+ org = resolvePartition(bindableFor(ev)).org;
1272
+ }
1273
+ catch {
1274
+ return; // unresolvable partition → in-memory registry only (fail-open)
1275
+ }
1276
+ for (const a of atts) {
1277
+ if (a.id && a.url) {
1278
+ args.waitUntil(persistSlackAttachment(a.id, { urlPrivate: a.url, mimeType: a.mimeType, name: a.name, teamId: ev.teamId }, org));
1279
+ }
1280
+ }
1281
+ };
1282
+ // 🔴 A TENANT REFUSAL STILL HAS TO HAND THE PLATFORM ITS WRITES (cold review, 2026-09-10).
1283
+ // The fence tracks its `surface.wake_dropped` row with `holdRouteWork`, which only pushes the
1284
+ // promise into THIS delivery's pending bag (slack-route-hold.ts). The bag is drained by
1285
+ // `routeHoldQuiesced`, and the one place that runs it is the wrapped `waitUntil` this handler was
1286
+ // given (see `held` in the route builder above). So a branch that refuses and returns 200 WITHOUT
1287
+ // calling `args.waitUntil` hands the platform nothing: the serverless invocation freezes the
1288
+ // moment the response is written and the row never lands. The refusal itself is unaffected — it
1289
+ // is decided before any of this — but the audit row is the drop's ONLY durable signal, because a
1290
+ // webhook function's console output does not reach the operator. One empty `waitUntil` is enough:
1291
+ // the wrapper quiesces the whole tracked bag, not just the promise handed to it.
1292
+ const fencedAck = (res) => {
1293
+ args.waitUntil(Promise.resolve());
1294
+ return res;
1295
+ };
1296
+ if (decision.kind === "forward") {
1297
+ // A fresh Request so slackChannel can re-read the body and re-verify the signature — and, on a
1298
+ // body carrying an unfenced attachment, one built over the fenced bytes. See `forwardNative`.
1299
+ return forwardNative();
1300
+ }
1301
+ if (decision.kind === "dm-wake") {
1302
+ // A DM-FAMILY message — a 1:1 (OPE-351 stage 2) or a group DM (stage 4). OURS, UNCONDITIONALLY:
1303
+ // diverted into the surface's own standing window rather than forwarded to eve's native
1304
+ // `onDirectMessage` (1:1) or handed to the engagement gate that refused it (group DM). The
1305
+ // `OE_DM_SEAT_ROTATION=0` escape hatch that used to short-circuit to a native forward here is
1306
+ // gone (OPE-368) — it was the last supported way to leave a DM un-windowed, i.e. un-wakeable by
1307
+ // a delegated worker's completion, which is precisely the seat class the retired OPE-306
1308
+ // delegation anchor existed to serve.
1309
+ const dev = parseMessageEvent(decision.env);
1310
+ // An unparseable envelope has nothing to wake on — a no-op 200. It carries no channel and no
1311
+ // ts, so it cannot be keyed to a surface and gets a log line rather than an audit row.
1312
+ if (!dev) {
1313
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} wake dropped — the envelope parsed to no channel/ts`);
1314
+ return OK();
1315
+ }
1316
+ // 🔴 THE TENANT FENCE, AS EARLY AS THIS BRANCH CAN ASK IT (OPE-1644). `scheduleDmWake` below
1317
+ // is the chokepoint and refuses the same delivery on its own, so this is not the fence — it is
1318
+ // what keeps an unadmitted workspace from spending THESE on the way there: the bot-author
1319
+ // probe's audit rows, `persistAttachments` (which registers a FOREIGN workspace's
1320
+ // `url_private` files for a delegated worker to fetch) and a durable dedupe write all sit
1321
+ // between here and it. The parse above has already seeded the in-memory attachment LRU, which
1322
+ // is the one cost no placement in this branch avoids.
1323
+ if (slackFrontTenantFenceDeclined(dev, `dm-${decision.dmKind}`, "front-dm-branch"))
1324
+ return fencedAck(OK());
1325
+ // A bot-authored DM is never a wake — a no-op 200. NOT a forward: forwarding a bot_message
1326
+ // would hand eve's native door a turn the DM window is meant to own.
1327
+ //
1328
+ // 🔴 THE DROP IS COUNTED (OPE-1022). This used to be a bare `return OK()`, which made the DM
1329
+ // door the one wake path in the deployment that could swallow a real, well-formed event with
1330
+ // no audit row and no log line. Slack stamps `bot_id` on every Web-API post, a user token
1331
+ // included, so any script-driven probe lands here — and on 2026-08-23 that silence read as a
1332
+ // dead Slack surface for a drop that was working exactly as designed. Our own reply echo is
1333
+ // excluded inside the helper, so the row means "somebody else's bot spoke here", not chatter.
1334
+ //
1335
+ // 🔴 NAMED EXCEPTIONS ONLY (OPE-1023, OPE-1739, OPE-1754). `wake.e2eDrivers` lists the
1336
+ // deployment's e2e driver identities across every surface, and this door reads its `slack`
1337
+ // entries alone — each a `teamId:botId` pair. A LISTED identity's DM is admitted and woken
1338
+ // like a human's. Nothing but a whole-pair match is admitted. Empty is the
1339
+ // shipped value, and an empty list matches nothing in `isE2eDriver`, so an unconfigured
1340
+ // deployment reaches the drop below with the same verdict and the same row it always had.
1341
+ // `getBotIdentity()` is only awaited AFTER a pair matches, which is why the hot echo leg
1342
+ // gains no lookup.
1343
+ //
1344
+ // The self-check is the loop guard: an operator who wrote OUR OWN bot record into the setting
1345
+ // would otherwise have every reply re-wake the seat forever. Refusing the match costs nothing
1346
+ // (the identity read is a cached promise) and removes the only configuration that could loop.
1347
+ //
1348
+ // The leading `dev.botId` test is what keeps the HUMAN path free: without it every human DM
1349
+ // would resolve the settings object to ask a question only a bot author can answer.
1350
+ const driverAdmitted = Boolean(dev.botId) && dmDoorAdmitsDriver(dev) && dev.botId !== (await getBotIdentity()).botId;
1351
+ if (driverAdmitted) {
1352
+ // LOUD, not silent: the row is what lets a soak review tell an admitted driver DM from a
1353
+ // human DM. It rides `waitUntil` for the same reason the drop's row does — the route 200s
1354
+ // and wakes before the write would land.
1355
+ args.waitUntil(auditDmDriverAdmitted(dev, decision.dmKind).catch(() => {
1356
+ /* observability only — never block or change the decision */
1357
+ }));
1358
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM ADMITTED — the named e2e driver identity (bot ${dev.botId} in team ${dev.teamId ?? "?"})`);
1359
+ // …and then FALLS THROUGH to the ordinary DM path below. No separate wake: the whole point
1360
+ // is that the driver's DM takes the same route a human's does.
1361
+ }
1362
+ else if (dev.botId) {
1363
+ // The identity read and the audit write both ride `waitUntil`, so neither delays the 200.
1364
+ // That matters most on the echo leg: Slack re-delivers every reply the bot posts, so this
1365
+ // is the hottest path through the DM door, and it must stay as fast as the bare drop was.
1366
+ // `.catch` because an unhandled rejection inside `waitUntil` can fail the invocation, and
1367
+ // an observability write must never change what the route does.
1368
+ args.waitUntil((async () => {
1369
+ const { botId: selfBotId } = await getBotIdentity();
1370
+ if (await auditDmBotAuthorDrop(dev, decision.dmKind, selfBotId)) {
1371
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: bot-authored DM dropped — no turn (bot ${dev.botId} in team ${dev.teamId ?? "?"}; the DM door admits no bot author)`);
1372
+ }
1373
+ })().catch(() => {
1374
+ /* observability only — never block or change the decision */
1375
+ }));
1376
+ return OK();
1377
+ }
1378
+ // 🔴 MENTION-SIBLING SUPPRESSION, GROUP DM ONLY. Slack fires BOTH an `app_mention` and a
1379
+ // `message.mpim` for a group-DM message that @mentions the bot, and the mention branch below
1380
+ // diverts the app_mention into this same seat — so without this drop the room would answer
1381
+ // twice. It is the exact rule the channel wake path applies to a `message.channels` sibling,
1382
+ // applied here because the DM branch returns before ever reaching that code.
1383
+ //
1384
+ // …AND DELIBERATELY NOT FOR A 1:1, where it would be the more dangerous mistake. The mention
1385
+ // branch does not divert an `im` (see there), so nothing else owns a 1:1's mention-bearing
1386
+ // message; suppressing it would turn "@bot are you there?" in a DM into total silence. Whether
1387
+ // Slack even fires app_mention inside an `im` is unmeasured here, and a rule whose failure mode
1388
+ // is a DEAF DM is not the place to bet on a vendor behaviour nobody has checked. Fail-open on an
1389
+ // unresolvable bot identity (same posture as the channel path): an occasional double beats a drop.
1390
+ //
1391
+ // 🔴 AND NOT FOR AN EDIT, for the reason {@link mentionSiblingSuppressed} states in full: Slack
1392
+ // raises no `app_mention` for a `message_changed`, so there is no second event to prefer and
1393
+ // the drop is total silence. The rule is the SHARED helper rather than a copy — this leg and
1394
+ // the channel leg refused the same edit for the same wrong reason, and a second copy is how
1395
+ // the next exemption reaches only one of them.
1396
+ if (decision.dmKind === "mpim") {
1397
+ const { userId: dmBotUserId } = await getBotIdentity();
1398
+ if (mentionSiblingSuppressed({
1399
+ authoredText: dev.authoredText,
1400
+ ...(dev.subtype === undefined ? {} : { subtype: dev.subtype }),
1401
+ ...(dmBotUserId === undefined ? {} : { botUserId: dmBotUserId }),
1402
+ })) {
1403
+ return OK();
1404
+ }
1405
+ }
1406
+ persistAttachments(dev); // durable upload registry (task #107) — so a delegated worker can read this DM's files
1407
+ // Same two-layer idempotency as every other wake candidate: a Slack RETRY must not double-wake.
1408
+ if (!(await notYetSeen(decision.env, dev)))
1409
+ return OK();
1410
+ // Serialized on the surface's OWN FIFO key — a DM's bursts must not interleave, and its queue is
1411
+ // never shared with a channel supervisor's or a thread's. The mention-carried divert below
1412
+ // enqueues on the SAME key, so a group DM's two legs can never run concurrently on one seat.
1413
+ args.waitUntil(fifo.enqueue(dmFifoKey(dev.channelId), async () => {
1414
+ await deliverWithStrandRetry(() => scheduleDmWake(dev, decision.dmKind, driverAdmitted));
1415
+ console.log(`[open-everywhere:slack-front] ${decision.dmKind} ${dev.channelId}/${dev.ts}: woke — diverted to the DM window seat`);
1416
+ }));
1417
+ return OK();
1418
+ }
1419
+ if (decision.kind === "shortcut") {
1420
+ // 🔴 A SHORTCUT IS AN ADDRESSED WAKE. Somebody pressed a named button aimed at this app, so
1421
+ // there is nothing to judge: it takes the `direct-mention` road, exactly like a single-window
1422
+ // @mention and a DM, and never the engagement or name-drop judge that exists to answer "was
1423
+ // the agent addressed?". What it does NOT bypass is any gate: the shortcut is mapped onto the
1424
+ // ordinary ParsedMessageEvent and handed to the SAME dispatchers, so the partition, seat,
1425
+ // window, spend gate, external-participant policy and wake-trust tier are all derived by the
1426
+ // code a mention already goes through. This branch adds a wake ORIGIN, not a plane.
1427
+ const sc = decision.shortcut;
1428
+ // A GLOBAL shortcut names no conversation, so the invoker's own DM window is where it lands —
1429
+ // the one room the person unambiguously chose by pressing a button with no message in scope.
1430
+ // A message shortcut lands in the conversation the message lives in.
1431
+ const shortcutChannelId = sc.kind === "message" ? sc.channelId : await openDirectMessageChannel(sc.userId);
1432
+ if (!shortcutChannelId) {
1433
+ // Fail-closed, and NAMED: this is the one degradation an operator can act on (grant
1434
+ // `im:write` and reinstall). Slack still gets its fast ack — a shortcut that wakes nothing
1435
+ // must not also look like a broken endpoint.
1436
+ console.log(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" from ${sc.userId}: no channel to wake in ` +
1437
+ `(a global shortcut opens the invoker's DM, which needs the im:write bot scope) — nothing woken`);
1438
+ return new Response(null, { status: 200 });
1439
+ }
1440
+ // A message shortcut can be invoked anywhere the app can see a message, so two facts have to
1441
+ // be ASKED for — a `message_action` payload carries neither. A global shortcut skips the
1442
+ // lookup: its channel is the `im` we just opened, so both answers are already known.
1443
+ // • `channelType` picks the road (DM window vs channel supervisor). It is the same gap an
1444
+ // `app_mention` envelope has;
1445
+ // • `appCanRead` says whether this app is in the room at all, which gates the target
1446
+ // message's FILES below.
1447
+ const shortcutFacts = sc.kind === "global"
1448
+ ? { channelType: "im", appCanRead: true }
1449
+ : await channelFactsFor(shortcutChannelId)
1450
+ .then((f) => ({ channelType: f.channelType, appCanRead: f.appCanRead === true }))
1451
+ .catch(() => ({ channelType: undefined, appCanRead: false }));
1452
+ const shortcutChannelType = shortcutFacts.channelType;
1453
+ if (!shortcutChannelType) {
1454
+ // 🔴 AN UNRESOLVED CHANNEL TYPE DROPS (OPE-1442). This used to fall through to the channel
1455
+ // road, which is wrong for the case that actually produces an unresolved type: a DM or a
1456
+ // group DM whose `conversations.info` blipped. A shortcut is ADDRESSED, so the channel road
1457
+ // carries `directMention` into the CHANNEL-SUPERVISOR session for this id — and a DM's
1458
+ // content would land in a channel-shaped seat on a transient API failure. There is no
1459
+ // reading of "I could not tell what room this is" that justifies routing it anyway.
1460
+ console.warn(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
1461
+ `conversations.info resolved no channel type — dropped (a shortcut never routes on an unresolved room)`);
1462
+ return new Response(null, { status: 200 });
1463
+ }
1464
+ // 🔴 THE FILE GATE (OPE-1442). Slack delivers a `message_action` for a room the app is NOT in,
1465
+ // and the payload carries that message's `url_private`. Registering it would let the bot token
1466
+ // download bytes out of a room the agent's own channel reads refuse, through a door the
1467
+ // channel-read allowlist never sees. So the files ride only on a POSITIVE readability answer;
1468
+ // otherwise the references are dropped from the event as well, and the wake carries the
1469
+ // message's text alone. The wake itself still happens — the invoker is the principal and the
1470
+ // body is fenced, so the TEXT crossing is the ruled Slack-forward precedent. The BYTES are not.
1471
+ if (sc.kind === "message" && !shortcutFacts.appCanRead && (sc.message?.attachments?.length ?? 0) > 0) {
1472
+ console.warn(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}" in ${shortcutChannelId}: ` +
1473
+ `withheld ${sc.message?.attachments?.length} file reference(s) — the app cannot read that conversation`);
1474
+ }
1475
+ const sev = shortcutMessageEvent(sc, shortcutChannelId, shortcutChannelType, now(), { admitAttachments: shortcutFacts.appCanRead });
1476
+ // 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1644 / OPE-1645). Both roads out of
1477
+ // this branch are fenced further down — a DM-shaped shortcut by `scheduleDmWake`, a channel
1478
+ // shortcut by `routeSupervisorEvent` — so this refuses the delivery before either, and before
1479
+ // the attachment registry is seeded from it. It is also the door the ORG-WIDE Grid payload
1480
+ // arrives at: such a payload names an enterprise and NO workspace, which `sev` carries as
1481
+ // `enterpriseId` with `teamId` absent, and which the fence now judges on the enterprise
1482
+ // instead of admitting as "names no tenant".
1483
+ //
1484
+ // IT SITS BELOW THE TWO LOOKUPS ABOVE, not above them, because it needs `sev` to write a row
1485
+ // keyed to a surface. Those lookups cost an unadmitted workspace one `conversations.open` or
1486
+ // `conversations.info` on OUR OWN bot token, which answers `user_not_found` /
1487
+ // `channel_not_found` for a foreign id and discloses nothing.
1488
+ if (slackFrontTenantFenceDeclined(sev, `${sc.kind}-shortcut`, "front-shortcut-branch"))
1489
+ return fencedAck(new Response(null, { status: 200 }));
1490
+ // Keyed on the click, not the message — a person may invoke the same shortcut on the same
1491
+ // message twice on purpose, and only Slack's redelivery of ONE click is a duplicate.
1492
+ if (!(await notYetSeenKey(shortcutWakeKey(sc), sev)))
1493
+ return new Response(null, { status: 200 });
1494
+ // BOTH halves of the attachment registry, seeded ONLY on a click that reaches a DISPATCHER —
1495
+ // the in-memory one for this process's seat, the durable one for a delegated worker in
1496
+ // another. (A dispatcher can still refuse further down, as `routeSupervisorEvent`'s own
1497
+ // fail-closed internal confirmation does; that residual click touches the LRU.)
1498
+ // They sit behind every gate above for the reason `parseSlackShortcutPayload`'s header states:
1499
+ // the in-memory map is a 1000-entry LRU shared with every real upload, so seeding it from
1500
+ // clicks that wake nothing evicts records `describe_image` needs.
1501
+ const seedAttachments = () => {
1502
+ registerAttachments(sev);
1503
+ persistAttachments(sev);
1504
+ };
1505
+ if (shortcutChannelType === "im" || shortcutChannelType === "mpim") {
1506
+ seedAttachments();
1507
+ args.waitUntil(fifo.enqueue(dmFifoKey(shortcutChannelId), async () => {
1508
+ await deliverWithStrandRetry(() => scheduleDmWake(sev, shortcutChannelType));
1509
+ console.log(`[open-everywhere:slack-front] ${sc.kind} shortcut "${sc.callbackId}": woke the ${shortcutChannelType} window ${shortcutChannelId}`);
1510
+ }));
1511
+ return new Response(null, { status: 200 });
1512
+ }
1513
+ // A CHANNEL shortcut takes the addressed single-window road. On a deployment whose domain does
1514
+ // NOT run the unified window there is no road into a channel seat for anything but a native
1515
+ // @mention, which a shortcut cannot become — `scheduleUnownedWake` refuses a `directMention`
1516
+ // it cannot deliver with a loud warn rather than falling through to the silence-biased
1517
+ // name-drop judge, and that refusal is the honest outcome here too. See the runbook.
1518
+ scheduleUnownedWake(sev, `${sc.kind}-shortcut`, true, seedAttachments);
1519
+ return new Response(null, { status: 200 });
1520
+ }
1521
+ if (decision.kind === "mention") {
1522
+ // An `app_mention`. Default: forward to eve's native slackChannel exactly as before —
1523
+ // byte-identical for every non-single-window domain. SINGLE-WINDOW (SINGLE-LEG divert): when
1524
+ // the shared seam says the unified window owns mentions on this POSITIVELY-internal channel,
1525
+ // we divert the app_mention EVENT ITSELF into the supervisor as an addressed direct-mention
1526
+ // wake and NEVER forward. ONE decision on ONE webhook: the `message.channels` sibling is
1527
+ // ALWAYS dropped (wake path below, unchanged from today), so nothing depends on it — there is
1528
+ // no separate suppress-leg / deliver-leg pair that could disagree across a webhook, TTL
1529
+ // eviction, or a cold instance, so neither a lost-mention nor a double-answer is possible.
1530
+ // It also handles a public-channel app_mention that fires NO sibling at all. Fail-closed: an
1531
+ // unparseable event, unresolvable partition, or any policy error ⇒ forward (today's path).
1532
+ const mev = parseMessageEvent(decision.env);
1533
+ if (mev)
1534
+ persistAttachments(mev); // durable upload registry (task #107) — before the owned/forward branch
1535
+ let owned = false;
1536
+ if (mev) {
1537
+ try {
1538
+ owned = await singleWindowOwnsMention(mev.channelId, resolvePartition(bindableFor(mev)).surfaceKey);
1539
+ }
1540
+ catch {
1541
+ owned = false; // fail-closed → the native mention path keeps ownership
1542
+ }
1543
+ }
1544
+ if (owned && mev) {
1545
+ // Idempotency on the app_mention's own event_id (a Slack retry must not double-wake).
1546
+ if (!(await notYetSeen(decision.env, mev)))
1547
+ return OK();
1548
+ console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: single-window — diverting to the unified supervisor (native forward suppressed)`);
1549
+ // The supervisor route lifts the app_mention/channel_type disqualifiers for this
1550
+ // directMention path (slack-events.ts routeSupervisorEvent) and re-checks its own
1551
+ // fail-closed internal confirmation — the SAME process-local TTL-cached conversations.info
1552
+ // the policy just populated, so the two reads on this one webhook are cache-coherent.
1553
+ scheduleUnownedWake(mev, "single-window-mention", true);
1554
+ return OK();
1555
+ }
1556
+ // 🔴 THE GROUP-DM MENTION LEG (OPE-351 stage 4). An @mention is how a human addresses the bot in
1557
+ // a multi-party room, so it is the MOST common way an mpim wakes — and left forwarding it would
1558
+ // have split the group DM in two: mentions answered by a native, un-wakeable, per-thread eve
1559
+ // session, everything else by the standing window this stage just gave the room. That split is
1560
+ // not a cosmetic inconsistency; the native session is exactly the seat OPE-351 exists to
1561
+ // abolish, so shipping the message leg alone would leave the headline defect (a seat that
1562
+ // delegates and can never be told the answer arrived) unfixed for the room's main entrance.
1563
+ //
1564
+ // IT COSTS A LOOKUP, because a raw `app_mention` envelope carries NO `channel_type` — the front
1565
+ // route genuinely cannot tell a group DM's mention from a channel's without asking Slack. The
1566
+ // read is the shared TTL-cached `conversations.info` (see slackChannelFactsFor for why that is
1567
+ // cheaper than the uncached read the forward it replaces triggers inside eve).
1568
+ //
1569
+ // SCOPED TO `mpim`, NOT TO "DM-FAMILY": an `im` mention still forwards, byte-identically to
1570
+ // stage 2. The 1:1 message leg does not suppress its mention-bearing sibling (see the dm-wake
1571
+ // branch), so diverting an `im` app_mention as well would double-answer every DM in which
1572
+ // somebody typed the bot's name. Fail-closed everywhere: an unresolved channelType (a workspace
1573
+ // without `mpim:read`) or any error ⇒ the native forward below, which is precisely pre-stage-4
1574
+ // behaviour. 🔴 THAT RESIDUAL FORWARD IS THE ONE REMAINING WAY A DM-FAMILY MESSAGE REACHES A
1575
+ // NATIVE-TOKEN SEAT (OPE-368 removed the deliberate `OE_DM_SEAT_ROTATION=0` route). Such a seat
1576
+ // has no window token, so `canDelegateFromSeat` is false, `agent` does not mount, and the memory
1577
+ // index says `"none"` — the honest pre-OPE-306 posture, not a dead affordance.
1578
+ if (mev) {
1579
+ const mentionChannelType = await channelFactsFor(mev.channelId)
1580
+ .then((f) => f.channelType)
1581
+ .catch(() => undefined);
1582
+ if (mentionChannelType === "mpim") {
1583
+ if (!(await notYetSeen(decision.env, mev)))
1584
+ return OK();
1585
+ console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: group DM — diverting to the mpim window seat (native forward suppressed)`);
1586
+ // The event's own `channel_type` is absent, so the divert is TOLD it is an mpim rather than
1587
+ // re-reading a field that was never there; `scheduleDmWake` stamps it onto the wake auth,
1588
+ // which is what makes `bindSlack` bind mode `mpim` on this leg exactly as on the other.
1589
+ args.waitUntil(fifo.enqueue(dmFifoKey(mev.channelId), async () => {
1590
+ await deliverWithStrandRetry(() => scheduleDmWake({ ...mev, channelType: "mpim" }, "mpim"));
1591
+ }));
1592
+ return OK();
1593
+ }
1594
+ if (mentionChannelType === undefined) {
1595
+ // NAMED, not silent: this is the one degradation an operator can act on (grant `mpim:read`
1596
+ // and reinstall). Everything else about the mention is unchanged, so it is a log line, not
1597
+ // a failure — a channel mention reaches here too whenever conversations.info is unavailable.
1598
+ console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: channel type unresolved (conversations.info unavailable; a group-DM mention needs the mpim:read scope) — forwarding to the native mention path`);
1599
+ }
1600
+ }
1601
+ // ── THE EXTERNAL-PARTICIPANT POLICY, ON THE NATIVE FORWARD (OPE-507 / OPE-504) ──────────────
1602
+ //
1603
+ // 🔴 THIS IS THE LAST UNGATED SLACK WAKE, AND IT IS THE MOST LIKELY ONE. The three wake routes
1604
+ // in slack-events.ts and the DM seat above all read the policy — but an `app_mention` in an
1605
+ // ordinary NON-single-window channel takes none of them: it is handed to eve's native
1606
+ // `slackChannel`, which owns the turn end to end and has no seam the policy could reach. So the
1607
+ // gate has to sit HERE, above the forward, exactly where teams-front.ts puts its own.
1608
+ //
1609
+ // 🔴 IT COSTS A PROBE, SO IT IS SKIPPED WHEN IT CANNOT MATTER. Unlike the three routes, this
1610
+ // leg resolves no author facts of its own, so gating it means one `users.info` call. The inert
1611
+ // check runs first: on the shipped `taint` / `engage` pair no evidence is resolved at all and
1612
+ // this whole block is a single config read. The probe is TTL-cached per author on top of that.
1613
+ //
1614
+ // FAIL-OPEN on anything unreadable — an unparseable event, an unresolvable partition, a probe
1615
+ // that cannot answer. Every carriage leg in this plane fails open, and a policy error must not
1616
+ // be the thing that takes a workspace's mentions off the air.
1617
+ if (mev) {
1618
+ try {
1619
+ const p = resolvePartition(bindableFor(mev));
1620
+ const roomKey = domainKeyOf(p);
1621
+ if (!externalPolicyIsInert(slackExternalPolicyForRoom(roomKey))) {
1622
+ const internal = (await channelFactsFor(mev.channelId).catch(() => ({ internal: false }))).internal;
1623
+ const facts = await authorFactsFor(internal, mev.userId, mev.teamId);
1624
+ const wakeTrust = resolveWakeTrust({
1625
+ authorIsBot: Boolean(mev.botId),
1626
+ authorId: mev.userId,
1627
+ surfaceInternal: facts.surfaceInternal,
1628
+ channelKey: roomKey,
1629
+ });
1630
+ const verdict = await applySlackExternalPolicy(mev, p, { trust: wakeTrust, refuted: facts.refuted }, "native-mention-forward");
1631
+ if (verdict !== "proceed") {
1632
+ console.log(`[open-everywhere:slack-front] app_mention ${mev.channelId}/${mev.ts}: suppressed — externalPolicy verdict "${verdict}" (native forward withheld)`);
1633
+ return OK();
1634
+ }
1635
+ }
1636
+ }
1637
+ catch {
1638
+ // fail-open → the native forward below keeps today's behaviour
1639
+ }
1640
+ }
1641
+ return forwardNative();
1642
+ }
1643
+ if (decision.kind === "approval-click") {
1644
+ // 🔴 THE TENANT FENCE AT THE INTERACTIVITY INGRESS (OPE-1680). ABOVE the handler, because the
1645
+ // handler is the whole consequence: it loads the pending row, CASes a decision, tests the
1646
+ // clicker against the config-as-code admin roster and EXECUTES the approved act. A foreign
1647
+ // workspace's `U…` reaching that is the aliasing the fence exists for — most sharply on a
1648
+ // payload naming NO workspace (an org-wide Grid install), where `handleApprovalPayload` passes
1649
+ // no `clickContext` and `resolveApprovalClick` then reads the clicker's tenant off the CARD's
1650
+ // own room, i.e. off the ADMITTED workspace. See `slackInteractivityTenantFenceDeclined`.
1651
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "approval-click", "front-interactivity-branch")) {
1652
+ return fencedAck(new Response(null, { status: 200 }));
1653
+ }
1654
+ // Ours: handled entirely by our code (load → CAS decide → approver check → execute →
1655
+ // chat.update), never Eve's. Fast empty 200 — Slack interactivity wants a quick ack,
1656
+ // not a rendered reply.
1657
+ //
1658
+ // 🔴 `args.waitUntil` RIDES ALONG (OPE-1709), and it is the HELD one (see the route hold
1659
+ // above), so the deferred work is quiesced before the delivery's own promise resolves. What
1660
+ // it moves off this path is the session-grant release: an "Approve for session" click on a
1661
+ // fan-out decides and EXECUTES the sibling cards the grant already covers, and that loop has
1662
+ // no business inside Slack's three-second interactivity budget.
1663
+ if (await handleApproval(decision.payload, { waitUntil: args.waitUntil })) {
1664
+ return new Response(null, { status: 200 });
1665
+ }
1666
+ // Defensive: classification said "ours" but the handler disagreed (a mismatch, e.g. a
1667
+ // stale/foreign action_id shape) — fall through to forwarding so the click is never
1668
+ // silently swallowed.
1669
+ return forwardNative();
1670
+ }
1671
+ if (decision.kind === "setkey-click") {
1672
+ // THE TENANT FENCE (OPE-1680), above the handler for the same reason the approval branch states.
1673
+ // The intake's owner check is over a bare per-workspace member id, so an unadmitted workspace's
1674
+ // click is a claim on somebody else's credential intake.
1675
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-click", "front-interactivity-branch")) {
1676
+ return fencedAck(new Response(null, { status: 200 }));
1677
+ }
1678
+ // Ours (credentials/intake.ts): verify the clicker owns the intake, then views.open the modal —
1679
+ // the trigger_id lives in the payload we already classified. Fast empty 200 (Slack wants a quick
1680
+ // interactivity ack; the modal opens via the views.open API call, not this response body).
1681
+ if (await handleSetKeyClick(decision.payload)) {
1682
+ return new Response(null, { status: 200 });
1683
+ }
1684
+ return forwardNative();
1685
+ }
1686
+ if (decision.kind === "connect-click") {
1687
+ // THE TENANT FENCE (OPE-1680), above the handler. This branch MINTS AN OAUTH URL against a
1688
+ // connector, so an unadmitted workspace reaching it is a foreign tenant driving this
1689
+ // deployment's connector plane on our own credentials.
1690
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "connect-click", "front-interactivity-branch")) {
1691
+ return fencedAck(new Response(null, { status: 200 }));
1692
+ }
1693
+ // Ours (connections/connector-card.ts): re-verify standing host-side, then mint an ephemeral
1694
+ // OAuth URL / open the api-key intake. Fast empty 200 (Slack wants a quick interactivity ack;
1695
+ // the ephemeral URL / intake button is posted via a separate API call, not this response body).
1696
+ if (await handleConnectClick(decision.payload)) {
1697
+ return new Response(null, { status: 200 });
1698
+ }
1699
+ return forwardNative();
1700
+ }
1701
+ if (decision.kind === "grant-click") {
1702
+ // THE TENANT FENCE (OPE-1680), above the handler — and above the vendor round trip below, which
1703
+ // is the most expensive thing an unadmitted workspace could make this deployment spend: the
1704
+ // approve leg opens a session under the OWNER's account (a consent probe, a token mint, an MCP
1705
+ // handshake and a `tools/list`) before it CASes.
1706
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "grant-click", "front-interactivity-branch")) {
1707
+ return fencedAck(new Response(null, { status: 200 }));
1708
+ }
1709
+ // Ours (access/credential-grant-card.ts): the OWNER answered a credential-grant ask in their
1710
+ // own DM. Core verifies the signed payload's `user.id` IS that ask's owner, then approves or
1711
+ // declines and answers the owner ephemerally through a separate API call.
1712
+ //
1713
+ // 🔴 ACK BEFORE THE VENDOR READ. An approval opens the owner's MCP connection before its
1714
+ // CAS — consent probe, token mint, handshake and `tools/list` — and a healthy cold catalogue
1715
+ // can cross Slack's three-second interactivity deadline. Classification and the tenant fence
1716
+ // have already proved this is our signed `oe_grant:` action, so the empty 200 goes out now and
1717
+ // the route's held `waitUntil` keeps exactly one resolution alive afterwards. The approve CAS
1718
+ // remains the exactly-once decider if Slack redelivers a click whose first ACK was lost.
1719
+ //
1720
+ // A handler disagreement is an invariant violation after classification, not a reason to
1721
+ // forward after acknowledging: eve does not own `oe_grant:` and cannot answer it. Keep it loud
1722
+ // without rejecting the host's background promise or changing the already-sent response.
1723
+ args.waitUntil(Promise.resolve()
1724
+ .then(async () => {
1725
+ if (!(await handleGrantCardClick(decision.payload))) {
1726
+ console.warn("[open-everywhere:slack-front] classified grant click was not claimed by the grant handler");
1727
+ }
1728
+ })
1729
+ .catch(() => {
1730
+ // The handler is total in production. Keep an impossible injected/runtime rejection
1731
+ // visible without putting vendor or credential-bearing error text into the route log.
1732
+ console.warn("[open-everywhere:slack-front] grant click failed after acknowledgement");
1733
+ }));
1734
+ return new Response(null, { status: 200 });
1735
+ }
1736
+ if (decision.kind === "input-click") {
1737
+ // THE TENANT FENCE (OPE-1680), above the handler, for the same reason every other interactivity
1738
+ // branch carries it: a tap RESUMES a parked turn, which is this deployment spending on a scope
1739
+ // an unadmitted workspace never had standing to decide.
1740
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "input-click", "front-interactivity-branch")) {
1741
+ return fencedAck(new Response(null, { status: 200 }));
1742
+ }
1743
+ // Ours (core channels/approval-card.ts): somebody tapped a bridged eve approval this
1744
+ // deployment posted onto a unified origin. The handler resolves the ticket, admits the tap
1745
+ // through the SAME floor the typed answer takes, and delivers `respond([{requestId, optionId}])`
1746
+ // to the parked WINDOW session.
1747
+ //
1748
+ // 🔴 THE DELIVERY GOES BY SESSION ID (OPE-1863). `args.from` is CHANNEL-LOCAL — on this route
1749
+ // it addresses `slack:<token>`, while the parked session lives at `unified:<token>` — so the
1750
+ // settle threw "the target session was not found" and every tap left the approval parked.
1751
+ // `args.attachSession` dispatches by durable session id from any channel, and
1752
+ // `approvalCardRespondVia` is the one road every surface binds. Bound HERE and injected because
1753
+ // the route handler is the only place eve hands out either primitive.
1754
+ //
1755
+ // The 200 goes out AFTER the settle, deliberately, and the cost is stated rather than hidden:
1756
+ // Slack expects this endpoint to answer in about three seconds, and a settle that runs longer
1757
+ // leaves the tapper waiting on whatever Slack does then. Acking first and settling in the
1758
+ // background would fix the delay and WIDEN the handler's own non-atomic window — see the
1759
+ // "THE LIMIT OF THAT" paragraph in `slack-approval-card.ts`, which also records that the
1760
+ // past-deadline behaviour is unmeasured here.
1761
+ const respondToApproval = approvalCardRespondVia(args.attachSession);
1762
+ const tap = await handleApprovalCardClick(decision.payload, respondToApproval);
1763
+ console.log(`[open-everywhere:slack] oe_input: tap — ${tap.reason}`);
1764
+ // `handled:false` means the payload carried our prefix but not a shape the handler could read.
1765
+ // Forward it rather than swallow it, the same defensive shape the grant and setkey branches
1766
+ // take: a click this deployment cannot act on is better handed on than silently dropped.
1767
+ if (!tap.handled)
1768
+ return forwardNative();
1769
+ return new Response(null, { status: 200 });
1770
+ }
1771
+ if (decision.kind === "setkey-submit") {
1772
+ // THE TENANT FENCE (OPE-1680), above the handler — this is the branch that WRITES A SECRET into
1773
+ // this deployment's store, keyed to the intake the payload names. A `view_submission` carries
1774
+ // no `channel`, which is the case the fence's row writer keys to `SLACK_INTERACTIVITY_SURFACE`.
1775
+ // The refusal answers with the SAME empty 200 a successful close does, never the `errors`
1776
+ // object: an inline validation message would tell a foreign tenant it reached a live modal.
1777
+ if (slackInteractivityTenantFenceDeclined(interactivityFenceIds(decision.payload), "setkey-submit", "front-interactivity-branch")) {
1778
+ return fencedAck(new Response(null, { status: 200 }));
1779
+ }
1780
+ // Ours: encrypt + store the pasted value host-side (the model never sees it). A view_submission
1781
+ // must answer with a JSON body — empty ⇒ Slack closes the modal (success), an `errors` object ⇒
1782
+ // inline validation (expired/used/blank). `handled:false` (a callback_id we don't own slipped
1783
+ // through) falls through to forwarding.
1784
+ const result = await handleSetKeyViewSubmission(decision.payload);
1785
+ if (result.handled) {
1786
+ return result.response
1787
+ ? new Response(JSON.stringify(result.response), { status: 200, headers: { "content-type": "application/json" } })
1788
+ : new Response(null, { status: 200 });
1789
+ }
1790
+ return forwardNative();
1791
+ }
1792
+ if (decision.kind === "join") {
1793
+ // Channel-join intro. Only the BOT's own join is an invitation — a human joining a
1794
+ // channel we're in fires the same event and must be dropped. Fail-CLOSED here, the
1795
+ // opposite polarity from the mention suppression below: unresolved identity means we
1796
+ // can't prove the joiner is us, so nobody wakes (vs. "an occasional double is better
1797
+ // than a dropped reply" for suppression).
1798
+ const jev = parseChannelJoinEvent(decision.env);
1799
+ if (!jev)
1800
+ return OK();
1801
+ const { userId: joinBotUserId } = await getBotIdentity();
1802
+ if (!joinBotUserId || jev.userId !== joinBotUserId)
1803
+ return OK();
1804
+ // Same two-layer idempotency as the message wake: Slack retries a slow ack, and a
1805
+ // double intro is exactly the annoyance the durable half exists to prevent.
1806
+ const joinKey = wakeKey(decision.env, jev.channelId, jev.ts);
1807
+ if (dedupe.seen(joinKey, now()))
1808
+ return OK();
1809
+ dedupe.mark(joinKey, now());
1810
+ try {
1811
+ const org = resolvePartition(bindableForJoin(jev)).org;
1812
+ if (!(await markEventSeen(org, joinKey, new Date(now()).toISOString())))
1813
+ return OK();
1814
+ }
1815
+ catch {
1816
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
1817
+ }
1818
+ const joinSend = send; // the same 0.31 `from(address).send(…)` adapter built above
1819
+ // A fresh join can't contend with an existing thread FIFO (its thread root is the
1820
+ // join notice itself), so no queueing — same reasoning as the name-drop wake.
1821
+ args.waitUntil(routeChannelJoinEvent(jev, liveChannelJoinDeps(joinSend)).then(logOutcome("channel-join", jev.channelId, jev.ts)));
1822
+ return OK();
1823
+ }
1824
+ if (decision.kind === "reaction") {
1825
+ // Inbound reaction (feedback signal). Parse it; an unusable envelope is a no-op 200.
1826
+ const rev = parseSlackReactionEvent(decision.env);
1827
+ if (!rev)
1828
+ return OK();
1829
+ // 🔴 THE TENANT FENCE (OPE-1680), and this branch is on the fence's list for a reason the other
1830
+ // five do not share: the ✋ STOP arm reaches `args.attachSession`, eve's I/O-free CANCEL door,
1831
+ // so an unadmitted workspace's reaction can HALT a running session in the admitted one. The
1832
+ // rest of the branch also writes a durable dedupe row and records feedback against our own
1833
+ // replies. A reaction arrives on the Events API rather than the interactivity URL, so its
1834
+ // workspace is the one the PARSER already read off the envelope, and its Grid rung comes from
1835
+ // `slackEnterpriseIdFromEnvelope` — the same two fields `fenceDeclined` reads for a message.
1836
+ //
1837
+ // IT SITS BELOW THE PARSE, not above it: the parse is pure and free, and it is what lets the
1838
+ // refusal row name the channel the reaction happened in rather than a placeholder.
1839
+ const reactionEnterpriseId = slackEnterpriseIdFromEnvelope(decision.env);
1840
+ if (slackInteractivityTenantFenceDeclined({
1841
+ ...(rev.teamId === undefined ? {} : { teamId: rev.teamId }),
1842
+ ...(reactionEnterpriseId === undefined ? {} : { enterpriseId: reactionEnterpriseId }),
1843
+ channelId: rev.channelId,
1844
+ userId: rev.reactorId,
1845
+ }, "reaction", "front-reaction-branch")) {
1846
+ return fencedAck(OK());
1847
+ }
1848
+ // Same two-layer idempotency as every other wake candidate: a Slack RETRY (same
1849
+ // event_id) must not double-record a reaction. In-memory first, then the durable store
1850
+ // (a retry usually lands on a fresh serverless instance). Fail-open on an unresolvable
1851
+ // partition — dedupe prevents a double row, not a security hole.
1852
+ const rkey = wakeKey(decision.env, rev.channelId, rev.messageTs);
1853
+ if (dedupe.seen(rkey, now()))
1854
+ return OK();
1855
+ dedupe.mark(rkey, now());
1856
+ try {
1857
+ const org = resolvePartition(bindableForReaction(rev)).org;
1858
+ if (!(await markEventSeen(org, rkey, new Date(now()).toISOString())))
1859
+ return OK();
1860
+ }
1861
+ catch {
1862
+ /* unresolvable partition → in-memory dedupe only (fail-open, see liveMarkEventSeen) */
1863
+ }
1864
+ // Resolve the bot identity once (cached) so the route can tell the bot's own reaction
1865
+ // from a human's and whether the target is the bot's own reply. A reaction can't
1866
+ // contend with any thread FIFO (it starts no turn), so no queueing — just fire it.
1867
+ // `args.attachSession` rides in for the ✋ STOP arm (OPE-418): it is eve's I/O-free cancel
1868
+ // door, and handing it in here is what lets a stop happen PRE-DISPATCH — no session mint,
1869
+ // no model call, and therefore no spend and no cap to be over.
1870
+ const identity = await getBotIdentity();
1871
+ args.waitUntil(routeReactionEvent(rev, liveReactionDeps(identity, args.attachSession)).then((o) => console.log(`[open-everywhere:slack-front] reaction ${rev.channelId}/${rev.messageTs}: ${o.recorded ? "recorded" : "not recorded"} — ${o.reason}`)));
1872
+ return OK();
1873
+ }
1874
+ // Wake path. Parse the message; a non-message or id-less envelope is a no-op 200.
1875
+ const ev = parseMessageEvent(decision.env);
1876
+ if (!ev)
1877
+ return OK();
1878
+ persistAttachments(ev); // durable upload registry (task #107) — so a delegated worker can read this message's files
1879
+ // Mention-sibling suppression — see {@link mentionSiblingSuppressed} for the whole rule and for
1880
+ // why an EDIT is exempt from it.
1881
+ const { userId: botUserId } = await getBotIdentity();
1882
+ if (mentionSiblingSuppressed({ authoredText: ev.authoredText, ...(ev.subtype === undefined ? {} : { subtype: ev.subtype }), ...(botUserId === undefined ? {} : { botUserId }) })) {
1883
+ return OK();
1884
+ }
1885
+ // Idempotency: a retried (or duplicated) event must not double-wake. In-memory first (free,
1886
+ // same-instance), then the durable store (cross-instance — a Slack retry usually lands on a
1887
+ // fresh serverless instance). One small statement, well inside Slack's 3s ack window.
1888
+ if (!(await notYetSeen(decision.env, ev)))
1889
+ return OK();
1890
+ if (!ev.threadTs) {
1891
+ // TOP-LEVEL message: never an engagement wake — but possibly a name-drop candidate
1892
+ // (per-domain opt-in, fail-closed; slack-events.ts routeNameDropEvent), or — when
1893
+ // ambientSupervisor is on for this domain — a supervisor wake. The reply thread it may
1894
+ // start is rooted at this message's ts, so a name-drop can't contend with any existing
1895
+ // FIFO chain; the supervisor wake serializes on the supervisor token instead.
1896
+ scheduleUnownedWake(ev, "name-drop");
1897
+ return OK();
1898
+ }
1899
+ // SEATED-CHANNEL short-circuit (task #100): where the channel has a standing seat, that seat owns
1900
+ // ALL in-thread traffic. A mention was diverted into it (no native per-thread session exists), so
1901
+ // an engaged thread's follow-up must ride the seat, NEVER the native per-thread token below
1902
+ // (routeMessageEvent), or it would wake a fresh empty session off the wrong token. The seat's own
1903
+ // deterministic stop leg (slack-events.ts routeSupervisorEvent) honours the engagement row a
1904
+ // diverted mention enrolls.
1905
+ //
1906
+ // 🔴 THE ENGAGEMENT READ IS BACK, AND OPE-1695 IS WHY. This branch used to fire only on a WINDOW
1907
+ // domain, which implied `ambientSupervisor`, so reaching the seat road was guaranteed. Every
1908
+ // channel is seated now, so in an ADDRESSED-ONLY room the same call would fall through to the
1909
+ // name-drop judge and answer nothing — the seat would reply to a mention and then go deaf on its
1910
+ // own thread. The read says which it is: an ENGAGED thread (the seat is holding this
1911
+ // conversation) takes the seat road whatever the chattiness flag says; a thread nobody engaged is
1912
+ // ordinary room traffic and still obeys the flag. Fail-closed: an unreadable row is NOT engaged.
1913
+ const p = resolvePartition(bindableFor(ev));
1914
+ if (seatedSurface(p.surfaceKey)) {
1915
+ const seatEngaged = await isThreadEngaged(p, ev.threadTs).catch(() => false);
1916
+ scheduleUnownedWake(ev, "in-thread-window", false, undefined, seatEngaged);
1917
+ return OK();
1918
+ }
1919
+ // In-thread branch (one engagement read decides it): an ENGAGED thread takes the
1920
+ // delivery path (rolled-in reply); a NON-engaged in-thread message runs the trigger
1921
+ // check instead — a name-drop can now pull the bot into a live sub-thread it wasn't in
1922
+ // (slack-events.ts shouldNameDropWake no longer disqualifies inThread).
1923
+ // Fail-closed isThreadEngaged reads an error as NOT engaged → the hard-gated trigger
1924
+ // path, never a spurious delivery.
1925
+ const engaged = await isThreadEngaged(p, ev.threadTs);
1926
+ if (!engaged) {
1927
+ // Trigger-check path: like the top-level name-drop it roots on the existing thread
1928
+ // and can't contend with a FIFO chain, so it follows the same non-queued waitUntil
1929
+ // shape rather than the per-thread queue (the supervisor branch enqueues on its own key).
1930
+ scheduleUnownedWake(ev, "name-drop-thread");
1931
+ return OK();
1932
+ }
1933
+ // Per-thread FIFO: serialize this thread's bursts; hand the promise to waitUntil so the
1934
+ // route returns 200 now while the wake continues in the background. (The FIFO is
1935
+ // deliberately per-instance: cross-instance ORDERING would need a lease/queue, and the
1936
+ // failure mode — two burst replies racing — is cosmetic, unlike the duplicate-delivery
1937
+ // problem the durable dedupe above closes.)
1938
+ const threadKey = slackContinuationToken(ev.channelId, ev.threadTs);
1939
+ args.waitUntil(fifo.enqueue(threadKey, () => routeMessageEvent(ev, liveDeps(send)).then(logOutcome("engaged-thread", ev.channelId, ev.threadTs))));
1940
+ return OK();
1941
+ }
1942
+ }
1943
+ /**
1944
+ * Compose the engagement front route with a wrapped `slackChannel`. `inner` must have been
1945
+ * built with `route: INTERNAL_SLACK_ROUTE`. Returns the SAME channel value (preserving Eve's
1946
+ * internal `__kind`/adapter/receive) with the public front route prepended — so Slack's URL
1947
+ * hits us, and both routes share one channel name → one session namespace.
1948
+ */
1949
+ export function frontSlackChannel(inner, opts = {}) {
1950
+ const innerPost = inner.routes.find((r) => r.method === "POST" && r.path === INTERNAL_SLACK_ROUTE);
1951
+ if (!innerPost) {
1952
+ throw new Error(`frontSlackChannel: wrapped slackChannel must be built with route: "${INTERNAL_SLACK_ROUTE}" ` +
1953
+ `(found paths: ${inner.routes.map((r) => r.path).join(", ")})`);
1954
+ }
1955
+ const front = buildFrontHandler(innerPost.handler, opts);
1956
+ return { ...inner, routes: [POST(PUBLIC_SLACK_ROUTE, front), ...inner.routes] };
1957
+ }
1958
+ //# sourceMappingURL=slack-front.js.map