@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,1654 @@
1
+ import { resolvePartition, type AttachSessionForCancel, type SeatCatchUpRequest, type SeatCatchUpResult, type ExternalPolicy, type IngestedChatMessage, PREFER_NO_REPLY_TOOL, type Partition, type BindableContext, type SingleWindowMentionPolicy, type UnifiedWindowWake, type ReplyDecision, type EngagementRow, type RoomRoster, type ContextLine, type WakeAttachment, type WakeMessageInput, type WakeParticipant, type WakeTrust, type NormalizedWake, type WakeEngagementTier } from "@open-everywhere/core/agent/adapter-api.js";
2
+ import { type SpendGateDecision } from "./slack-spend-gate.js";
3
+ import { type WakeBackscrollLines } from "./slack-wake-backscroll.js";
4
+ export { PREFER_NO_REPLY_TOOL };
5
+ import { type E2eDriverIdentity, type FinalMessageAction, type ReactionSinkDeps, type ReactionIngestOutcome, type ReactionVerb } from "@open-everywhere/core/agent/adapter-api.js";
6
+ /**
7
+ * Minimal Slack `event_callback` envelope shape (the subset the parser reads). Mirrors
8
+ * Eve's internal `SlackEventCallback` without depending on a type it doesn't re-export.
9
+ */
10
+ export interface SlackEventEnvelope {
11
+ readonly type?: string;
12
+ readonly team_id?: string;
13
+ /** The Enterprise Grid organisation the event's workspace belongs to (`E…`). Slack sends it on the
14
+ * ENVELOPE, beside `team_id`, and only for a Grid install (OPE-1508). */
15
+ readonly enterprise_id?: string;
16
+ readonly event?: Record<string, unknown> & {
17
+ readonly type?: string;
18
+ };
19
+ readonly [key: string]: unknown;
20
+ }
21
+ /**
22
+ * The Enterprise Grid organisation id an event envelope names, or `undefined` (OPE-1508).
23
+ *
24
+ * 🔴 THE ENVELOPE IS THIS FUNCTION'S ONLY SOURCE. Slack puts `enterprise_id` beside `team_id` on the
25
+ * `event_callback` envelope and does NOT repeat it on the inner message object, so an ingress that
26
+ * only ever holds an inner event resolves nothing HERE and must carry the rung another way. Every
27
+ * ingress now carries it, and each reads exactly one source:
28
+ * 1. the wake paths driven from THIS file's own parses, and the group-DM/DM wake in
29
+ * `slack-front.ts` — they hold the envelope, so they call this function;
30
+ * 2. the NATIVE MENTION forward, the common ingress. `slack-front.ts` forwards an ordinary
31
+ * `app_mention` to eve's native channel handler (the single-window divert is the exception),
32
+ * and eve hands that handler a `SlackMessage` whose `raw` is the inner event, with the envelope
33
+ * already discarded. So the front route calls this function and stows the answer around the
34
+ * forward (`withForwardedSlackEnvelope`, slack-identity.ts), and `slack.ts`'s `authFromFacts`
35
+ * reads it back for that one message (OPE-1559);
36
+ * 3. the SHORTCUT ingress. A shortcut arrives on the interactivity route and has no event envelope
37
+ * at all, so `slack-shortcut.ts` reads the payload's own `enterprise.id` OR `team.enterprise_id`
38
+ * — Slack's two spellings for the same rung, one per install shape — into
39
+ * `ParsedSlackShortcut.enterpriseId`, and `shortcutMessageEvent` spreads it onto the
40
+ * `ParsedMessageEvent` (OPE-1559);
41
+ * 4. the `/oe` SLASH COMMAND door, which has its own route and its own urlencoded body.
42
+ * `slack-commands.ts` reads the `enterprise_id` FORM FIELD straight into the bindable context
43
+ * it hands `bindSlack` (OPE-1559).
44
+ * All four end at the same `enterprise_id` auth attribute, so one operator declaration answers for
45
+ * every door. Each source describes the ROOM's Grid; none of them reads the author's.
46
+ *
47
+ * The AUTHOR leg does not depend on any of this: it reads the author's own workspace and Grid from
48
+ * `users.info` ({@link slackAuthorIsMember}), so who counts as a member is decided the same way on
49
+ * every ingress.
50
+ *
51
+ * A non-string or empty value is `undefined` — the same defensive read `team_id` gets, because both
52
+ * are Slack's raw JSON.
53
+ */
54
+ export declare function slackEnterpriseIdFromEnvelope(envelope: SlackEventEnvelope): string | undefined;
55
+ /** A Slack `message` event narrowed to the fields the wake path needs. */
56
+ export interface ParsedMessageEvent {
57
+ readonly channelId: string;
58
+ /** The message ts. */
59
+ readonly ts: string;
60
+ /** Thread root ts — present only for a threaded reply (engagement is per-thread). */
61
+ readonly threadTs?: string;
62
+ readonly teamId?: string;
63
+ /** The Enterprise Grid organisation the event's workspace sits in, when the envelope named one
64
+ * ({@link slackEnterpriseIdFromEnvelope}). The trust walk's rung above the workspace. */
65
+ readonly enterpriseId?: string;
66
+ readonly userId?: string;
67
+ readonly botId?: string;
68
+ readonly subtype?: string;
69
+ readonly channelType?: string;
70
+ /** Plain text (mrkdwn) of the reply: the author's own text, plus any QUOTED SLACK MESSAGE
71
+ * Slack parked in the message's legacy `attachments[]` — a forward, a share, or a message
72
+ * permalink unfurl — appended under it ({@link resolveSlackInboundText}).
73
+ *
74
+ * The de-dup unit is one EXTRACTED FIELD (an attachment's `text`, a block's line), never a
75
+ * physical line: a field is kept or dropped whole. A field is dropped when the author's own
76
+ * text already carries it as a phrase, or when the SAME attachment restated it. Two
77
+ * DIFFERENT attachments carrying the same field both survive — that is two quoted messages
78
+ * that agree, not a duplicate. A message with no quoted-message attachment is unchanged. */
79
+ readonly text: string;
80
+ /**
81
+ * The author's OWN typed text — `inner.text` verbatim, with no quoted content merged in.
82
+ *
83
+ * Use this, never {@link text}, for any rule that asks "what did this person SAY?" — the
84
+ * mention-sibling suppression and the name-drop wake needle both do. Reading the merged
85
+ * `text` there makes the agent act on words the author never typed: a forward of a message
86
+ * that @mentions the bot would be suppressed as a mention sibling, and a forward of a message
87
+ * naming the agent would wake it though nobody named it here. See the suppression in
88
+ * slack-front.ts for what is measured and what is assumed about Slack's `app_mention`.
89
+ */
90
+ readonly authoredText: string;
91
+ /**
92
+ * Pre-edit text, from Slack's `previous_message` — present ONLY for a `message_changed`
93
+ * edit (undefined otherwise, including a message_changed missing the field). Feeds the
94
+ * wake envelope's `<previous-body>` (wake-envelope.ts) so a judged turn can diff old↔new.
95
+ */
96
+ readonly previousText?: string;
97
+ /** True when the text @mentions the bot — those go through the normal mention path. */
98
+ readonly isAppMention: boolean;
99
+ /** Image/file attachment REFERENCES parsed off Slack's `files[]` (docs Part B) — surfaced in
100
+ * the wake envelope so the agent knows an image exists and can `describe_image` it. Empty/
101
+ * absent when the message carries no files. Never the bytes — just id/name/mimetype/url. */
102
+ readonly attachments?: readonly WakeAttachment[];
103
+ }
104
+ /** Parse Slack's `files[]` (on a message event's inner message) into attachment references.
105
+ * Only well-formed entries survive; an image mimetype ⇒ kind "image" (describable), else
106
+ * "file" (referenced, not yet readable). Pure; tolerant of Slack's loose shapes. */
107
+ export declare function parseSlackFiles(files: unknown): WakeAttachment[];
108
+ /**
109
+ * The text the model should see for one inbound Slack message: the author's own `text`, plus
110
+ * any QUOTED SLACK MESSAGE Slack put in `attachments[]` (a forward, a share, or a message
111
+ * permalink unfurl — see {@link hasSharedMessageAttachment}, which deliberately EXCLUDES a
112
+ * plain link unfurl) appended under it. A quoted field the author already typed is dropped, as is a field
113
+ * one attachment restates within itself. A field two DIFFERENT attachments carry is kept twice
114
+ * — that is two quoted messages that happen to agree, not a duplicate.
115
+ *
116
+ * IDENTITY on the common path: a message with no legacy `attachments[]`, and one whose
117
+ * attachments yield no new line, return the SAME `text` string unchanged. Only a message
118
+ * that actually carries unseen quoted content grows.
119
+ *
120
+ * 🔴 TRUST: the quoted content is merged INTO the author's body, so it INHERITS the forwarder's
121
+ * trust tier — it does not carry its own. `authorTrustAdmitsUnfencedBody` (wake-envelope.ts)
122
+ * keys on the author, so a vouched member's forward renders `from="human" trust="peer"` and the
123
+ * whole body is delivered UNFENCED: no `<untrusted_external_data>` wrapper, and no attribution
124
+ * separating the forwarder's words from the quoted author's. A member can therefore hand the
125
+ * model an outside author's words under their own standing. The body is still XML-escaped, so
126
+ * this is a standing question, not a structural one. It IS a widening of what a forward can put in
127
+ * front of the model, and it is deliberate. Attributing the quoted block to its own author is the
128
+ * improvement this leaves on the table.
129
+ *
130
+ * 🔴 THE SAME BOUNDARY NOW HOLDS ON BOTH PATHS (OPE-1380). eve's own inbound path merges legacy
131
+ * attachments too (`inbound.js` calls `resolveSlackInboundMrkdwn`), and it applies no such filter —
132
+ * so the `@mention` forward used to admit the plain LINK unfurl this function refuses.
133
+ * {@link stripUnfencedSlackAttachments} removes those attachments from the body before the forward,
134
+ * which is how the native path is held to `hasSharedMessageAttachment` without reaching inside eve.
135
+ * A quoted Slack message still reaches the model on both paths; scraped page text now reaches it on
136
+ * neither.
137
+ */
138
+ /**
139
+ * Drop from a RAW webhook body every legacy attachment that is not a quoted Slack message, so the
140
+ * NATIVE forward path obeys the same boundary the diverted path does (OPE-1380).
141
+ *
142
+ * 🔴 WHY THIS EXISTS AT ALL. OPE-1374 fenced OUR path: {@link resolveSlackInboundText} reads only
143
+ * attachments {@link hasSharedMessageAttachment} admits, so a plain LINK unfurl's scraped page text
144
+ * never reaches the model. A direct `@mention` does not take that path — `slack-front.ts` forwards
145
+ * the request to eve's native `slackChannel`, whose own extractor
146
+ * (`dist/src/public/channels/slack/inbound-content.js`, `resolveSlackInboundMrkdwn`) flattens EVERY
147
+ * legacy attachment into the message body. It uses `hasSharedMessageAttachment` only to pick a merge
148
+ * STRATEGY, never to decide what may be extracted. So the same link unfurl the wake path refuses was
149
+ * merged, unfenced and untainted, into a `trust="peer"` member message on the mention path.
150
+ *
151
+ * WE CANNOT FENCE INSIDE EVE, SO WE FENCE WHAT EVE IS GIVEN. The attachment is removed from the body
152
+ * before the forward. The author's own `text` is untouched — including the URL they typed, which is
153
+ * the only part of a link unfurl a person actually wrote.
154
+ *
155
+ * 🔴 IDENTITY ON THE COMMON PATH, and that is load-bearing rather than an optimisation. A body with
156
+ * no `event.attachments`, and one whose attachments are ALL quoted Slack messages, is returned as
157
+ * the SAME string — so its Slack signature still verifies at the inner handler and the forward stays
158
+ * byte-identical to today's. Only a body that really carries an unfenced attachment is rewritten,
159
+ * and `slack-front.ts` re-signs exactly those.
160
+ *
161
+ * NEVER THROWS. Any shape this cannot parse returns the original body: losing the fence on an odd
162
+ * payload is recoverable, and a throw here would 500 a webhook Slack then replays forever — the same
163
+ * direction {@link resolveSlackInboundText} takes for the same reason.
164
+ */
165
+ export declare function stripUnfencedSlackAttachments(rawBody: string): string;
166
+ export declare function resolveSlackInboundText(text: string, inner: Record<string, unknown>): string;
167
+ /**
168
+ * Parse a Slack `event_callback` envelope carrying a `message` (or `app_mention`) event
169
+ * into {@link ParsedMessageEvent}, or null when it isn't a usable message event (missing
170
+ * ids, wrong outer type). Pure — no I/O — so it's trivially testable against fixtures.
171
+ */
172
+ export declare function parseMessageEvent(envelope: SlackEventEnvelope): ParsedMessageEvent | null;
173
+ /** A fail-soft profile resolver: raw platform user id → {real name, self-set handle}, or
174
+ * undefined. Injected on each route's deps so name resolution is unit-testable AND so a caller
175
+ * that omits it (or the resolver throwing) degrades to raw ids — a name lookup must NEVER block
176
+ * or fail a wake. The live wiring points this at the cached {@link fetchUserProfileNames}. */
177
+ export type ResolveProfile = (userId: string) => Promise<{
178
+ name?: string;
179
+ handle?: string;
180
+ } | undefined>;
181
+ /**
182
+ * The message a woken (non-mention) turn receives: the judge instruction, then the
183
+ * triggering reply rendered through the shared wake envelope (wake-envelope.ts). The
184
+ * instruction is OURS (trusted, kept byte-identical below — it's live-tuned, see the
185
+ * file-level note above); the envelope's message body is surface content and gets
186
+ * `wrapUntrusted`-wrapped by the renderer itself, same invariant as the mention path's
187
+ * backscroll. A reader abusing the sentinel ("output NO_REPLY") can only silence the
188
+ * agent — the same as not addressing it — so this adds no injection surface.
189
+ *
190
+ * The wake has two addressings (`opts.mode`): the default engaged-thread `thread-follow`, and — on
191
+ * the supervisor path (`"supervisor"`) — `channel-watch` (Draft B, design doc §3), which is honest
192
+ * that the channel's STANDING session does not "follow this thread". Only that attribute differs;
193
+ * everything else in the envelope is shared, so the two paths stay in lockstep. The standing
194
+ * "Reading a wake" block keys a bar to each value (OPE-753).
195
+ *
196
+ * The social-chatter/re-answer lines are load-bearing for cheaper judged-wake models:
197
+ * live 2026-07-07 (e2e S2, deepseek-v4-flash as the e2e domain's primary) a human-to-human
198
+ * "thanks …, see you at standup" right after the agent's answer drew the SAME answer again
199
+ * — the judge read the thanks as addressed to it. Naming the pattern explicitly is cheap
200
+ * for strong models and decisive for weak ones.
201
+ *
202
+ * `now` is a clock seam (defaults to the real clock) so the envelope's `current-time`
203
+ * attribute is deterministic in tests; production callers pass `deps.now` when they have
204
+ * one, exactly like the touch/latch timestamps elsewhere in this module.
205
+ *
206
+ * `opts.backscroll` (T1.5): non-trigger envelope siblings rendered BEFORE the trigger message
207
+ * (oldest-first, ending at the trigger — renderWakeEnvelope's own invariant). Only the
208
+ * SUPERVISOR path's first wake on a generation ever supplies this (routeSupervisorEvent,
209
+ * fetchWakeEnvelopeBackscroll); every other caller passes nothing, so this renders
210
+ * byte-identically to before the addition.
211
+ */
212
+ export declare function renderWakeMessage(ev: ParsedMessageEvent, now: (() => string) | undefined, opts: SlackWakeOpts): string;
213
+ /** Options both Slack judged-wake builders accept — the exact shape {@link renderWakeMessage}
214
+ * exposed inline before the delegate refactor (unchanged caller-facing signature). */
215
+ export interface SlackWakeOpts {
216
+ readonly memberCount?: number;
217
+ /**
218
+ * Resolved per-wake trust tier for the TRIGGER author (identity/wake-trust.ts — the audit-wedge
219
+ * F3 assignment: roster admins → principal, internal members → peer, bots/external/unresolvable →
220
+ * relay, fail-closed). Every live route resolves and passes it.
221
+ *
222
+ * 🔴 REQUIRED, NOT OPTIONAL (OPE-596), and it is why `opts` itself is a REQUIRED argument on every
223
+ * builder that takes this bag. The field used to default to `"peer"`, which is fail-OPEN, and
224
+ * since OPE-577 the tier also decides whether the body carries the `<untrusted_external_data>`
225
+ * fence — so an absent tier REMOVED a fence. Leaving `opts` optional would have kept the same hole
226
+ * behind a different door: a caller that passes no bag at all still states no tier. A caller that
227
+ * cannot resolve a tier states the fail-closed one itself: `relay` for a machine author, `unauth`
228
+ * for a human the gate cannot vouch.
229
+ */
230
+ readonly trust: WakeTrust;
231
+ /** ENVELOPE-SIBLING backscroll (supervisor first-wake only): the carried TAIL of the outgoing
232
+ * generation, or — with no recorded tail — the capped platform-history fallback. */
233
+ readonly backscroll?: readonly WakeMessageInput[];
234
+ /** The trigger author's resolved display name / handle (fail-safe: absent ⇒ raw id in `author`). */
235
+ readonly triggerAuthorName?: string;
236
+ readonly triggerAuthorHandle?: string;
237
+ /** The `<participants>` id→name/handle block (fail-safe: absent ⇒ no block). */
238
+ readonly participants?: readonly WakeParticipant[];
239
+ /** Wake mode. Default (undefined) = the engaged-thread `thread-follow` addressing;
240
+ * `"supervisor"` = `channel-watch` — the channel supervisor is the channel's STANDING session, it
241
+ * doesn't "follow this thread". Only {@link routeSupervisorEvent} passes it. */
242
+ readonly mode?: "supervisor";
243
+ /** Engagement calibration for a `channel-watch` wake (memory/domain.ts engagementTierFor), rendered
244
+ * as the `<wake engagement>` attribute. Only the supervisor path passes it; unset ⇒ no attribute. */
245
+ readonly engagementTier?: WakeEngagementTier;
246
+ /**
247
+ * The trigger's thread carries a PARTICIPATING engagement row and is not stood down — render
248
+ * `follow-up="thread-engaged"`, the envelope's own word for "you are already in this
249
+ * conversation" (the standing "Reading a wake" block, instructions/access.ts). A WATCHER row
250
+ * (`participating: false`, what a name-drop latches) is not an enrolment and states nothing.
251
+ *
252
+ * 🔴 IT DOES NOT TOUCH `addressing`, DELIBERATELY. The supervisor path's `channel-watch` is the
253
+ * honest tier for a STANDING session (the Draft B decision this file's renderWakeMessage header
254
+ * records, pinned in slack-engagement-test.ts), and enrolment is a different fact from the
255
+ * silence bar. The two attributes were always meant to be read together; only one of them was
256
+ * ever emitted here.
257
+ *
258
+ * MEASURED (ship #195, F-EDIT / OPE-1673): the seat answered the thread root at 06:19:24Z, then
259
+ * declined the follow-up AND its edit as `not_relevant` — "not addressed to me" — because both
260
+ * wakes rendered as bare ambient activity. The engagement row `markEngaged` wrote for the root
261
+ * mention was in the store throughout; `routeSupervisorEvent` already reads it for the
262
+ * stood-down gate, so stating it costs no extra read.
263
+ *
264
+ * Unset/false ⇒ no attribute, byte-identical to before.
265
+ */
266
+ readonly threadEngaged?: boolean;
267
+ /** Host-minted wake id (rendered `<wake wake-id=…>`) — SINGLE-WINDOW unified path only
268
+ * (channels/unified-window.ts): the id the model echoes to `reply` so the host resolves the
269
+ * wake's origin from the map. Unset ⇒ byte-identical (no attribute). */
270
+ readonly wakeId?: string;
271
+ /** SINGLE-WINDOW addressed @mention (the GitHub reference's exact field —
272
+ * github-supervisor.ts GitHubSupervisorWakeInput.directMention): render the supervisor wake
273
+ * as `direct-mention` — the full judged envelope (so the `wake-id` survives for the reply
274
+ * tool, the ONLY reply route on the unified channel) but with the ADDRESSED preamble that
275
+ * tells the supervisor it was @mentioned and owes a reply, instead of the silence-biased
276
+ * channel-watch preamble. Unset/false ⇒ the channel-watch wake, byte-identical to today.
277
+ * Only routeSupervisorEvent's single-window mention path sets it (the front route kept the
278
+ * mention because the seam said the unified window owns it). */
279
+ readonly directMention?: boolean;
280
+ /** THE DM WAKE (OPE-351 stage 2): render this as a direct-message wake — envelope `reason="dm"`
281
+ * with the ADDRESSED preamble. Set only by slack-front.ts's `scheduleDmWake`. See
282
+ * {@link slackDmWake} for why a DM is always addressed. Unset ⇒ byte-identical to today. */
283
+ readonly dm?: boolean;
284
+ /**
285
+ * The surface POSITIVELY REFUTED the trigger author's membership — a guest, a Connect stranger.
286
+ * It rides to the render as the trigger message's own `refuted`, which is a ONE-WAY FENCE
287
+ * BACKSTOP (wake-envelope.ts {@link authorTrustAdmitsUnfencedBody}): it can only ADD the
288
+ * `<untrusted_external_data>` fence to a body whose TIER already reads member-grade, never remove
289
+ * one, and it never moves the reference-only note.
290
+ *
291
+ * 🔴 IT EXISTS FOR THE DM FAMILY, where the tier and the refutation come apart (OPE-1486). Every
292
+ * CHANNEL route folds its refutation into `surfaceInternal` first, so a refuted channel author is
293
+ * already `unauth` and already fenced — those routes state nothing here and are byte-identical. A
294
+ * GROUP DM deliberately keeps the ROOM's tier, so an invited guest is still answered at
295
+ * `principal`; without this field that guest's body would reach the model UNFENCED. Absent means
296
+ * "not refuted", which includes "the probe could not answer" — this leg fails OPEN.
297
+ */
298
+ readonly triggerRefuted?: boolean;
299
+ /**
300
+ * THE NAME-DROP TIER on a standing-seat wake (OPE-1725): a person said the agent's name in this
301
+ * room without @-mentioning it, and the name-drop judge admitted the wake. Renders
302
+ * `addressing="name-drop"` plus the `follow-up="follows-on-reply"` contract.
303
+ * 🔴 IT IS A TIER, NOT A SESSION. Set only by {@link routeSupervisorEvent}'s name-drop path, which
304
+ * is the seat road: the wake lands in the ROOM'S OWN SEAT with the earlier exchange already in
305
+ * context, never in a fresh eve-native per-thread session. {@link directMention} wins where both
306
+ * are somehow set — an @mention is the more addressed fact. Unset ⇒ the channel-watch wake,
307
+ * byte-identical to today.
308
+ */
309
+ readonly nameDrop?: boolean;
310
+ /** This surface cannot read its own history between wakes, so the seat took no catch-up
311
+ * (channels/seat-catch-up.ts `catchUpUnavailableReason`). Rendered as a trusted `<system-note>`;
312
+ * unset ⇒ no note, which is every Slack wake, because Slack registers a fetch. */
313
+ readonly catchUpUnavailable?: string;
314
+ /**
315
+ * THE NAME-DROP HYDRATION, ON THE SEAT ROAD (OPE-1725). The surrounding conversation
316
+ * {@link routeNameDropEvent} fetches for a name-drop, as RAW {@link ContextLine}s — renderWake
317
+ * wraps them through the shared `renderContext`, exactly as it does on the native route.
318
+ *
319
+ * 🔴 IT IS THE SIDE-CHANNEL SLOT, NOT THE ENVELOPE BACKSCROLL, and the two must not be confused.
320
+ * {@link backscroll} carries the seat's own bootstrap/catch-up read as envelope siblings; this
321
+ * carries the name-drop judge's own "what is being discussed" hydration, which is the ONE slot
322
+ * `withholdNameDropContext` fills (OPE-525). Set only by the seat road's name-drop path; unset ⇒
323
+ * no context block at all, byte-identical to an ambient seat wake.
324
+ *
325
+ * 🔴 IT CARRIES ONLY WHAT {@link backscroll} DOES NOT (OPE-1743). The caller de-duplicates the two
326
+ * slots by message id before it fills them, so one wake renders each room message ONCE: the
327
+ * envelope keeps the overlap with its per-message author, tier and send time, and this slot keeps
328
+ * the older conversation the envelope's window never reached. ONE EXEMPTION: a line whose author the
329
+ * surface positively REFUTED stays here even when the envelope carries the same message, because the
330
+ * two slots are graded by different legs and the envelope's copy may be graded by neither (OPE-1742).
331
+ */
332
+ readonly contextLines?: readonly ContextLine[];
333
+ /** `renderContext`'s provenance label for {@link contextLines}. */
334
+ readonly contextSource?: string;
335
+ /** OUR trusted read-more instruction, appended OUTSIDE the untrusted wrap by `renderContext`
336
+ * and only when the rendered slice fills the count cap. */
337
+ readonly readMoreHint?: string;
338
+ }
339
+ /** The engaged-conversation `thread-follow` judged wake (the default {@link renderWakeMessage} path). */
340
+ export declare function slackThreadFollowWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake;
341
+ /** The standing-session supervisor judged wake (routeSupervisorEvent's path): `channel-watch`
342
+ * addressing by default, or — when {@link SlackWakeOpts.directMention} says the single-window seam
343
+ * kept the @mention — the ADDRESSED `direct-mention` addressing, which keeps the envelope and its
344
+ * wake-id and tells the standing block this wake is owed an answer. */
345
+ export declare function slackChannelWatchWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake;
346
+ /**
347
+ * THE DM WAKE (OPE-351 stage 2; group DMs added at stage 4) — the first producer of the `dm` envelope
348
+ * reason, which has been in the vocabulary (channels/wake-envelope.ts {@link WakeReason}) since it was
349
+ * reverse-engineered from Tag with no emitter behind it.
350
+ *
351
+ * ADDRESSING IS `direct-mention`, ALWAYS, and it is doing two jobs at once. Semantically it is simply
352
+ * TRUE for a 1:1: everything said in a 1:1 DM is said to the agent, so a DM is never judged for whether
353
+ * it was addressed — the engagement-tier dial, the name-drop judge and the channel-watch silence bias
354
+ * are all category errors here (slack-front.ts routes a DM around routeSupervisorEvent entirely for that
355
+ * reason). Mechanically it is LOAD-BEARING: `direct-mention` is what stamps `oe_addressing` through
356
+ * the divert, which marks the wake as OWED a reply. It selects the `[direct]` preamble, whose reply
357
+ * mandate names the wake-id the model must echo back, and it classifies the drop row if the turn
358
+ * ends in prose anyway.
359
+ * 🔴 IT IS NOT A DELIVERY PATH (OPE-1982). It used to arm the unified channel's addressed-prose
360
+ * bridge, so a DM answer written as prose instead of a `reply` call still reached the room. That
361
+ * bridge is deleted: on the unified channel a turn's closing prose is DROPPED on every addressing
362
+ * and re-driven once towards the tool, so a DM answer reaches its room only through a `reply` call.
363
+ *
364
+ * A GROUP DM IS THE SAME WAKE. The addressing is identical (the drop classification and the reply mandate
365
+ * are needed just the same), and the room shape reaches the model on `<channel type="mpim">` against
366
+ * a 1:1's `type="im"`. The old `groupDm` flag selected the opening clause of a PROSE preamble that no
367
+ * longer exists, so OPE-770 deleted it — the schema render has no prose to select. The silence escape
368
+ * the reply mandate carries is what makes unconditional WAKING compatible with conditional REPLYING
369
+ * in a room that also holds other humans.
370
+ *
371
+ * `reason` is `dm` rather than `mention` because the envelope's reason describes the SURFACE SHAPE the
372
+ * wake arrived on, and telling a DM it was "@mentioned" is a small lie that costs orientation. A group
373
+ * DM keeps `dm` for the same reason: {@link WakeReason}'s own definition is "a direct-message-EQUIVALENT
374
+ * surface", which an mpim is — inventing a fourth value for it would extrapolate a Tag-derived
375
+ * vocabulary with no evidence behind the new entry.
376
+ */
377
+ export declare function slackDmWake(ev: ParsedMessageEvent, opts: SlackWakeOpts): NormalizedWake;
378
+ /**
379
+ * What `message.completed` (channels/slack.ts) should actually DO with a terminal
380
+ * {@link FinalMessageAction} ("silent" or "post" — never "buffer-tool-status", which is
381
+ * an intermediate step with no turn-final effects at all and is handled before this is
382
+ * called), given whether an approval card already posted THIS turn (a propose tool —
383
+ * tools/set_spend_limit.ts and its siblings — calling requestApproval; see
384
+ * approvals/card-signal.ts).
385
+ *
386
+ * "reply" is the outcome for BOTH an ordinary post and a card-suppressed one: a card
387
+ * posting mid-turn still means the agent ACTED and is a PARTICIPANT in this thread —
388
+ * 👀 clears, the thread engages, oeUninvited clears — exactly like any other reply.
389
+ * `postText: false` gates ONLY the actual `channel.thread.post` call (Tommy, live: "the
390
+ * card should suffice" — the card already says everything a narrated reply would
391
+ * repeat). A refusal/validation path in a propose tool never reaches requestApproval at
392
+ * all (see tools/set_spend_limit.ts's header), so its thrown error still surfaces as a
393
+ * normal `postText: true` reply — only a turn where a card was ACTUALLY posted is
394
+ * suppressed.
395
+ */
396
+ export type TurnEffects = {
397
+ readonly kind: "silent";
398
+ } | {
399
+ readonly kind: "reply";
400
+ readonly postText: boolean;
401
+ };
402
+ export declare function turnEffectsFor(action: Extract<FinalMessageAction, {
403
+ kind: "silent";
404
+ } | {
405
+ kind: "post";
406
+ }>, cardPostedThisTurn: boolean): TurnEffects;
407
+ /** Dependencies injected into {@link routeMessageEvent} so the decision is fully testable. */
408
+ export interface MessageRouteDeps {
409
+ /** Engagement lookup (defaults to the operational store via isThreadEngaged). */
410
+ readonly isEngaged: (p: ReturnType<typeof resolvePartition>, threadTs: string) => Promise<boolean>;
411
+ /** Read the full engagement record — used to set oeUninvited from the thread's
412
+ * `participating` flag. Optional so older test doubles keep compiling (absent ⇒
413
+ * fail-safe uninvited); live wiring passes the real store call. */
414
+ readonly getEngagement?: (p: Partition, threadTs: string) => Promise<EngagementRow | null>;
415
+ /** The wake — continue the thread's session with this reply. */
416
+ readonly wake: (input: {
417
+ channelId: string;
418
+ threadTs: string;
419
+ auth: unknown;
420
+ message: string;
421
+ state?: unknown;
422
+ }) => Promise<void>;
423
+ /** Refresh the engagement's last-turn time after a successful wake. */
424
+ readonly touch?: (p: ReturnType<typeof resolvePartition>, threadTs: string, atIso: string) => Promise<void>;
425
+ /** Clock seam for the touch timestamp (tests pass a fixed value). */
426
+ readonly now?: () => string;
427
+ /**
428
+ * Positively confirm the channel is internal (TTL-cached conversations.info,
429
+ * fail-closed) so the wake auth can carry the trust verdict. Optional: absent (or
430
+ * unconfirmed) the wake still delivers, just with an UNSTAMPED auth — the session
431
+ * fails closed to `external` (no shared memory / domain skills) for that turn.
432
+ */
433
+ readonly confirmInternal?: (channelId: string) => Promise<boolean>;
434
+ /** Append a durable audit row. Optional; best-effort. */
435
+ readonly audit?: (action: string, detail: Record<string, unknown>) => Promise<void>;
436
+ /** Resolve the trigger author's display name for the envelope (fail-soft). Optional so
437
+ * existing test doubles keep compiling AND so a wake never depends on a name lookup — absent
438
+ * or throwing ⇒ the raw id renders. Live wiring points this at {@link fetchUserProfileNames}. */
439
+ readonly resolveProfile?: ResolveProfile;
440
+ }
441
+ /** The outcome of routing one message event — the decision plus whether a wake fired. */
442
+ export interface RouteOutcome extends ReplyDecision {
443
+ readonly woke: boolean;
444
+ }
445
+ export declare function bindableFor(ev: ParsedMessageEvent): BindableContext;
446
+ /**
447
+ * Route one parsed message event: apply the delivery policy and, if it passes, wake the
448
+ * engaged thread's session. Returns the decision + whether a wake fired. All side effects
449
+ * go through injected deps so the policy ordering is unit-tested without Slack or a live
450
+ * runtime. The engagement registry is consulted ONLY when the cheap, pure disqualifiers
451
+ * pass (subtype/bot/mention/not-in-thread), keeping the DB off the hot path for chatter.
452
+ */
453
+ export declare function routeMessageEvent(ev: ParsedMessageEvent, deps: MessageRouteDeps): Promise<RouteOutcome>;
454
+ /**
455
+ * Production deps: engagement from the operational store, and a wake built over a route
456
+ * handler's `send`. `send` is the channel-route primitive that continues a session by
457
+ * continuation token — `slackContinuationToken(channel, thread)` is the Slack thread's
458
+ * token, so this delivers the reply straight into the live thread session.
459
+ */
460
+ /** The channel-route `send` shape the wake paths need (mirrors Eve's SendFn loosely). */
461
+ export type WakeSend = (input: {
462
+ message: string;
463
+ context?: readonly string[];
464
+ }, opts: {
465
+ auth: unknown;
466
+ continuationToken: string;
467
+ state?: unknown;
468
+ }) => Promise<unknown>;
469
+ /**
470
+ * The SPEND GATE at the ENGAGEMENT door (OPE-33) — the second call site of the ONE gate
471
+ * (slack-spend-gate.ts); the first is slack.ts `onInbound` (the mention door).
472
+ *
473
+ * WHY BOTH: on eve 0.27.5 the compiled slackChannel drops self-authored messages
474
+ * (`isSelfAuthoredSlackMessage`, matching `raw.app_id` against the envelope's `api_app_id`)
475
+ * BEFORE `onAppMention`/`onDirectMessage` run — and Slack stamps `app_id` on anything an app
476
+ * posts through the Web API, user token included. Every driver-app-authored mention therefore
477
+ * skips the mention door entirely and re-enters through THIS path (live-proven 2026-07-25:
478
+ * capped surfaces never refused). Gating only the mention handler is gating one of two doors.
479
+ *
480
+ * Refuse-and-drop semantics mirror the mention door exactly: over cap + no admin bypass ⇒ post
481
+ * the refusal, return "declined", and the caller dispatches NO model turn — DMs included
482
+ * (OPE-267 removed checkCap's DM exemption). Fail-open on any gate error (the helper's own
483
+ * console.warn).
484
+ *
485
+ * The refusal is posted through {@link postOnce}, keyed on (surface, thread root): an ambient
486
+ * channel over cap answers ONCE per conversation instead of once per message, while a genuinely
487
+ * new thread still gets told why it's being ignored. Fail-open by contract — a dedupe-store
488
+ * outage posts anyway.
489
+ */
490
+ export declare function slackWakeSpendGate(ev: ParsedMessageEvent, p: Partition, threadRootTs: string): Promise<SpendGateDecision>;
491
+ /**
492
+ * {@link tenantFenceDeclined} for a door the FRONT route owns — the diverted DM window and the
493
+ * shortcut ingress (OPE-1644).
494
+ *
495
+ * 🔴 WHY IT IS EXPORTED RATHER THAN COPIED. Those doors live in `slack-front.ts`, whose
496
+ * `scheduleDmWake` dispatches a full model turn onto the DM SEAT — the largest grant this
497
+ * deployment makes, holding a window token, delegation and memory — and which consulted no fence at
498
+ * all. A second copy of the rule at a second door is precisely the drift `slack-tenant-fence.ts`
499
+ * exists to prevent, so the front route calls THIS, and gets the same decision, the same audit row
500
+ * and the same silent-drop posture. Only `door` and `route` differ: an operator reading the trail
501
+ * needs to know which ingress refused.
502
+ */
503
+ export declare function slackFrontTenantFenceDeclined(ev: ParsedMessageEvent, door: string, route: string): boolean;
504
+ /**
505
+ * THE TENANT FENCE AT THE INTERACTIVITY CLICK INGRESS (OPE-1680) — the six `block_actions` /
506
+ * `view_submission` branches `slack-front.ts` owns, which arrive on the interactivity URL and carry
507
+ * no {@link ParsedMessageEvent}.
508
+ *
509
+ * 🔴 WHY A CLICK IS FENCED THOUGH IT RUNS NO MODEL TURN. What an unadmitted workspace reaches here is
510
+ * not a turn — it is the ALIASING the fence exists for, which is the same argument OPE-1633 used to
511
+ * fence `/oe`. On Slack a member id is issued PER WORKSPACE, so a foreign workspace's click is a
512
+ * bare `U…` that our own machinery must then decide about: it approves or rejects a pending action,
513
+ * opens a credential-intake modal, stores a pasted secret, mints an OAuth URL, answers a
514
+ * credential-grant ask, or cancels a running session. Every one of those consults the roster, writes
515
+ * audit rows into this deployment's trail, and posts back on OUR bot token.
516
+ *
517
+ * 🔴 WHAT THE FOREIGN CLICKER SEES: THE ACK, AND NOTHING ELSE. Slack still gets the empty 200 an
518
+ * interactivity route owes, so it does not retry. Nothing is POSTED back, for the reason every wake
519
+ * door gives — we cannot safely identify anybody in an unadmitted workspace, and a foreign tenant
520
+ * learning it reached a live deployment is more than it needs to know. "A silent button looks
521
+ * broken" does not apply: every one of these six buttons is posted BY THIS DEPLOYMENT, into a room
522
+ * it chose, and it posts into no unadmitted workspace — so a refused click has no legitimate reader
523
+ * waiting on it.
524
+ *
525
+ * 🔴 AND IT IS NOT A FORWARD. `forwardNative()` is the front route's fall-through when a handler
526
+ * disagrees with the classification; a tenant refusal must never take it, because eve's own channel
527
+ * asks no tenant question at all and would decide the same payload one layer down.
528
+ *
529
+ * The ROW is the same `surface.wake_dropped` row, carrying the same {@link UNADMITTED_TENANT_DROP_REASON},
530
+ * that every other door writes — so ONE control-API query still finds every tenant drop on this
531
+ * deployment. What differs is the key: a click has no message, so the row carries the click's own
532
+ * channel where the payload names one and {@link SLACK_INTERACTIVITY_SURFACE} where it does not, and
533
+ * its `ts` is `null` rather than an invented stamp. The partition resolves from those ids exactly as
534
+ * a message's does — the ORG is always this deployment's, and the platform/workspace/surface record
535
+ * the FOREIGN ids, which is what makes the row a statement about who was refused.
536
+ */
537
+ export declare function slackInteractivityTenantFenceDeclined(ids: {
538
+ readonly teamId?: string | undefined;
539
+ readonly enterpriseId?: string | undefined;
540
+ readonly channelId?: string | undefined;
541
+ readonly userId?: string | undefined;
542
+ }, door: string, route: string): boolean;
543
+ /**
544
+ * The audit reason a STOOD-DOWN thread's dropped wake records (OPE-1669), and it is exported so the
545
+ * contract test pins the door against the constant rather than against a copy of its wording.
546
+ *
547
+ * 🔴 IT IS THE ROW THE SHIP HAD NO WAY TO ASK FOR. On #191 a plain thread-follow into a thread whose
548
+ * `engagement.stood_down` row said `marked:true` was answered anyway, and the trail could not say
549
+ * whether the door had fired and the reply plane leaked, or the door had never fired at all — this
550
+ * drop wrote nothing, so its absence proved nothing. It writes one row now, carrying the ANCHOR, so
551
+ * it joins to `engagement.stood_down` on the engagement registry's own key.
552
+ * The old rationale for silence was log volume in a chatty stopped thread. It is answered, not
553
+ * ignored: this fires only on a thread that is BOTH engaged and stopped, which is a conversation the
554
+ * agent was explicitly asked to leave — bounded by real human traffic in one thread, and the one
555
+ * question an operator asks about such a thread is exactly the one this row answers.
556
+ */
557
+ export declare const STOOD_DOWN_DROP_REASON = "thread stopped \u2014 deterministic silence until an explicit @-mention re-invites";
558
+ /**
559
+ * The audit reason the DM DOOR records when it drops a bot-authored message (OPE-1022).
560
+ *
561
+ * Deliberately NOT {@link BOT_AUTHOR_DROP_REASON}. The engagement door drops only a bot the driver
562
+ * allowlist does not vouch for, so its row can blame the allowlist. The DM door admits NO bot
563
+ * author at all — an allowlisted driver's DM is dropped here too — so a row that said "is not an
564
+ * allowlisted driver" would send the reader to fix an allowlist that was never consulted. The
565
+ * `vouchedDriver` detail carries that distinction as data instead.
566
+ */
567
+ export declare const DM_BOT_AUTHOR_DROP_REASON = "bot author \u2014 the DM door admits no bot author";
568
+ /**
569
+ * Audit the DM door's bot-author drop, and answer whether a row was written (OPE-1022).
570
+ *
571
+ * WHY THIS EXISTS. `slack-front.ts`'s DM branch dropped every bot-authored `message.im` /
572
+ * `message.mpim` with a bare `return OK()` — no audit row, no log line. Slack stamps `bot_id` on
573
+ * ANYTHING an app posts through the Web API, a user token included, so an operator probing the
574
+ * deployment with a script sees the whole surface go silent and has nothing to read. That is the
575
+ * exact shape diagnosed on 2026-08-23: two probe DMs vanished with zero trace and read as "Slack
576
+ * inbound is dead", when the drop was working as designed and only its silence was the defect.
577
+ *
578
+ * OUR OWN ECHO IS NOT AUDITED. Slack re-delivers every reply the bot posts as a `message.im` event
579
+ * carrying our own `bot_id`, so an unconditional row would double every reply with noise and make
580
+ * the trail unreadable — the same reasoning that keeps the ordinary engagement non-candidates out
581
+ * of the log. `selfBotId` comes from `auth.test` and is best-effort: when it cannot be resolved we
582
+ * audit the echo too, because an over-full trail beats a silent one.
583
+ *
584
+ * Best-effort by contract: never throws, never changes the decision. The caller hands the returned
585
+ * promise to `waitUntil` so the write survives the route's immediate 200.
586
+ */
587
+ export declare function auditDmBotAuthorDrop(ev: ParsedMessageEvent, dmKind: "im" | "mpim", selfBotId: string | undefined): Promise<boolean>;
588
+ /**
589
+ * THE DM DOOR'S NAMED-DRIVER CARVE-OUT (OPE-1023, OPE-1739). True ⇒ this bot-authored DM is one of
590
+ * the deployment's
591
+ * NAMED e2e driver and the door admits it; false ⇒ the door drops it, which is what every bot
592
+ * author gets by default.
593
+ *
594
+ * WHY IT EXISTS. Slack stamps `bot_id` on anything an app posts through the Web API, a USER token
595
+ * included (measured 2026-08-23). So the e2e driver — which posts as a real human through an `xoxp`
596
+ * token — cannot drive a real Slack DM: its post reaches this door as bot-authored and drops. The
597
+ * harness worked around it by FORGING a signed `event_callback` with no `bot_id`, which meant every
598
+ * DM leg in the pack exercised a synthetic envelope and a regression in Slack's own DM delivery to
599
+ * us was invisible. This carve-out lets the driver in through the real front door.
600
+ *
601
+ * WHY IT IS NOT THE `OE_TEST_DRIVER_IDS` ALLOWLIST, which already lifts the same bot at the
602
+ * engagement and mention doors. A DM seat holds a window token, delegation and memory, so admitting
603
+ * a bot into a DM is a strictly larger grant than admitting one into a watched channel. Reusing the
604
+ * channel allowlist would have widened the DM door for every deployment that already sets it, on
605
+ * upgrade, with no diff naming the change. The setting is the diff.
606
+ *
607
+ * POSTURE (every part load-bearing):
608
+ * - EMPTY BY DEFAULT. The shipped `wake.e2eDrivers` is empty, and an empty list matches nothing
609
+ * in `isE2eDriver` — so an unconfigured deployment reaches the same verdict, and the same
610
+ * `surface.wake_dropped` row, that it did before this carve-out existed.
611
+ * - NAMED IDENTITIES. The setting holds a LIST of `{ surface, principal }` entries, and this door
612
+ * reads the `slack` ones alone — each a `teamId:botId` pair a bot is admitted by matching WHOLE.
613
+ * The list is long because our e2e pack drives two Slack accounts (OPE-1739) and several other
614
+ * surfaces besides (OPE-1754); it is a longer roll of named identities, not a wider grant, and
615
+ * the grammar has no syntax for "any bot" in a workspace.
616
+ * - EXACT MATCH, WORKSPACE-PINNED AND SURFACE-PINNED. `teamId` and `botId` must BOTH match on the
617
+ * SAME listed pair, on an entry whose `surface` is `slack`; a driver named for one workspace —
618
+ * or for another surface — has no standing here. No prefixes, no wildcards.
619
+ * - `bot_id` IS THE FIELD SLACK SENDS. A `message.im`/`message.mpim` event carries `bot_id` (and
620
+ * the human `user`); it does not carry `app_id` on the message itself, so the pair is written in
621
+ * the terms the payload actually uses.
622
+ * - LOUD. The caller writes a `surface.wake_admitted` row ({@link auditDmDriverAdmitted}), so a
623
+ * soak review can tell an admitted driver DM from a human DM.
624
+ *
625
+ * Loop safety is unaffected: OE's own reply echo carries OE's own bot record, and the front route
626
+ * refuses a match against its own identity before it admits anything.
627
+ */
628
+ export declare function dmDoorAdmitsDriver(ev: ParsedMessageEvent, configured?: readonly E2eDriverIdentity[]): boolean;
629
+ /** The audit reason the DM door records when {@link dmDoorAdmitsDriver} admits a bot author. One
630
+ * string, so a control-API query can find every such admission. */
631
+ export declare const DM_DRIVER_ADMIT_REASON = "e2e driver \u2014 the DM door admits a named driver identity";
632
+ /**
633
+ * Make the DM door's driver ADMISSION observable (OPE-1023) — the counterpart of
634
+ * {@link auditDmBotAuthorDrop}, which makes its refusals observable.
635
+ *
636
+ * A door that quietly widened for one identity would be worse than the silent drop OPE-1022 fixed:
637
+ * a soak reviewer reading a woken DM seat could not tell whether a human or the driver spoke. This
638
+ * row says which, and names the carve-out that let it through.
639
+ *
640
+ * Best-effort by contract: never throws, never changes the decision. The caller hands the returned
641
+ * promise to `waitUntil`, because the route wakes and returns before the write would land.
642
+ */
643
+ export declare function auditDmDriverAdmitted(ev: ParsedMessageEvent, dmKind: "im" | "mpim"): Promise<void>;
644
+ export declare function liveDeps(send: WakeSend): MessageRouteDeps;
645
+ export declare const _engagementWakeMode: (ev: ParsedMessageEvent) => import("@open-everywhere/core/agent/adapter-api.js").SessionMode;
646
+ /** One wake needle: an id (for logs/attribution) + the pattern that fires it. */
647
+ export interface WakeTrigger {
648
+ readonly id: string;
649
+ readonly pattern: RegExp;
650
+ }
651
+ /**
652
+ * The wake triggers for a channel: the unified wake token set (always first — see
653
+ * {@link wakeNameDropTokens}), plus any caller-supplied extras (a future per-domain hook — none
654
+ * today). `displayName` is the surface's live vendor display name, or `undefined` when the lookup
655
+ * failed. Kept a pure builder so the list is assembled the same way everywhere the route needs it.
656
+ */
657
+ export declare function buildWakeTriggers(displayName: string | undefined, extra?: readonly WakeTrigger[]): WakeTrigger[];
658
+ /** First trigger whose pattern matches `text`, or undefined when none do. */
659
+ export declare function matchWakeTriggers(text: string, triggers: readonly WakeTrigger[]): WakeTrigger | undefined;
660
+ /** Inputs to the pure name-drop policy — staged by {@link routeNameDropEvent}. */
661
+ export interface NameDropInput {
662
+ readonly inThread: boolean;
663
+ readonly authorIsBot: boolean;
664
+ readonly isAppMention: boolean;
665
+ readonly subtype?: string;
666
+ readonly channelType?: string;
667
+ readonly textMatchesName: boolean;
668
+ readonly domainEnabled: boolean;
669
+ readonly channelConfirmedInternal: boolean;
670
+ }
671
+ /**
672
+ * The name-drop policy. Every disqualifier before the single `deliver: true`, same
673
+ * fail-safe shape as {@link shouldDeliverReply}. Channels and private groups only —
674
+ * DMs always respond via the mention path, and group DMs are conversations the agent
675
+ * was explicitly added to person-by-person, where name-drop barging feels worst.
676
+ */
677
+ export declare function shouldNameDropWake(input: NameDropInput): ReplyDecision;
678
+ /**
679
+ * Did the built-in name-drop ABSTAIN on this message, as opposed to dropping it for policy?
680
+ *
681
+ * 🔴 THE WHOLE WAKE-ARM RUNG RESTS ON THIS DISTINCTION (OPE-1367, docs/extending/drop-in-arms.md).
682
+ * {@link shouldNameDropWake} answers `deliver: false` for seven different reasons and only ONE of
683
+ * them is "I have no opinion": the text does not name the agent. The other six are policy — a DM
684
+ * or group DM (the mention path already always-wakes a DM), a bot author, an `app_mention`, a
685
+ * message subtype, a domain that never enabled the name-drop wake, and a channel the internal probe
686
+ * could not confirm. A consumer's arm may fill the abstention. It may not overturn a policy drop.
687
+ *
688
+ * 🔴 DERIVED FROM THE BUILT-IN, NEVER RE-LISTED. This asks the built-in itself: with the name
689
+ * matched and everything else exactly as it is, would it wake? A hand-kept copy of the
690
+ * disqualifiers would go stale the first time somebody adds one to the policy above, and the arm
691
+ * would then reach a message the policy had just started dropping.
692
+ */
693
+ export declare function nameDropAbstained(input: NameDropInput): boolean;
694
+ /**
695
+ * The message a name-drop turn receives. A HIGHER bar than the engaged-thread judge:
696
+ * the agent was never invited into this conversation, so "when in doubt, stay out". The
697
+ * trigger renders through the same shared wake envelope as {@link renderWakeMessage} (see
698
+ * the T0.2/T0.3 note above it) — a top-level name-drop is `channel-activity`, an in-thread one
699
+ * is `thread-activity` (name-drop blanket-drops `message_changed`, see {@link
700
+ * shouldNameDropWake}, so it never sees an edit). `opts` carries the resolved trigger-author
701
+ * name/handle + `<participants>` block (fail-safe: absent ⇒ raw id in `author`, no block).
702
+ */
703
+ export declare function renderNameDropMessage(ev: ParsedMessageEvent, name: string, now: (() => string) | undefined, opts: {
704
+ /** Resolved per-wake trust tier — 🔴 REQUIRED (OPE-596). This wrapper used to omit the field
705
+ * from its own opts type entirely, so every render it produced took {@link slackNameDropWake}'s
706
+ * fail-OPEN `"peer"` default whatever the author's real tier was. */
707
+ readonly trust: WakeTrust;
708
+ readonly triggerAuthorName?: string;
709
+ readonly triggerAuthorHandle?: string;
710
+ readonly participants?: readonly WakeParticipant[];
711
+ }): string;
712
+ /** The uninvited `name-drop` judged wake ({@link routeNameDropEvent}'s path). No memberCount /
713
+ * envelope-sibling backscroll; a top-level trigger has no thread yet (replying CREATES
714
+ * one). `contextLines` (W3) carries the surrounding-conversation hydration as RAW lines — renderWake
715
+ * wraps them via the shared renderContext, so no pre-rendered string flows through the plumbing. */
716
+ export declare function slackNameDropWake(ev: ParsedMessageEvent, name: string, opts: {
717
+ readonly triggerAuthorName?: string;
718
+ readonly triggerAuthorHandle?: string;
719
+ readonly participants?: readonly WakeParticipant[];
720
+ readonly contextLines?: readonly ContextLine[];
721
+ readonly contextSource?: string;
722
+ readonly readMoreHint?: string;
723
+ /** Resolved per-wake trust tier — 🔴 REQUIRED, and `opts` with it (see
724
+ * {@link SlackWakeOpts.trust} for the whole reason). */
725
+ readonly trust: WakeTrust;
726
+ }): NormalizedWake;
727
+ /**
728
+ * Fetch the name-drop wake's surrounding conversation, RE-GRADE it and ADMIT it, on demand
729
+ * (OPE-1725, policed by OPE-1742).
730
+ *
731
+ * 🔴 IT IS A POLICED READ ON THE SEAT ROAD, AND ONLY THERE. Where `deps.seatRoute` is present the
732
+ * lines it returns have passed the domain's own admission policy (`seatAdmissionFor(channelKey)`,
733
+ * applied through channels/seat-admission.ts `admitContextLines`), the same policy the bootstrap
734
+ * backscroll and the since-last-wake catch-up apply — so a SEATED wake's three reads of the room
735
+ * admit one author identically. An UNSEATED deployment takes this same thunk and applies NO
736
+ * admission at all: its lines are byte-identical to the pre-OPE-1742 read, so a `members-only`
737
+ * domain with no seat still hydrates a refused author's line. That is deliberate, and the reason
738
+ * is at the call site ("THE SEAT ROAD ONLY") — `admission` is the STANDING SEAT's entry policy by
739
+ * declaration, and an unseated deployment has no seat for it to govern. `null` includes "the policy
740
+ * admitted nothing", which renders no context block rather than an empty one.
741
+ *
742
+ * 🔴 ONE HYDRATION, BOTH ROADS. `routeNameDropEvent` is the only SLACK route that fills the
743
+ * `contextLines` side-channel slot — Discord's gateway fills it too and calls no transform, so an
744
+ * excluded author's hydrated lines survive there (access/external-policy.ts `withholdWakeBodies`
745
+ * keeps that gap stated) — and `withholdNameDropContext` is the only thing that withholds a
746
+ * refuted author's bytes out of Slack's copy (OPE-525). When the judged wake hands off to the seat,
747
+ * the seat wake must carry THE SAME block — otherwise the room's hydration silently stops reaching
748
+ * the model on every seated deployment, and the withhold control has nothing left to act on.
749
+ *
750
+ * 🔴 IT IS A THUNK, NOT A VALUE, AND THAT IS WHAT KEEPS THE COST ORDER RIGHT. Each road pays for the
751
+ * history call only after ITS OWN tenant fence, spend gate and external-participant policy have
752
+ * admitted the wake — the seat road runs its copies inside {@link routeSupervisorEvent}, which is
753
+ * after the hand-off. Hydrating eagerly here would buy a Slack page for a wake the seat then refuses.
754
+ *
755
+ * `null` = nothing to add, or ANY failure: the fetch and the regrade are both fail-soft, so a wake
756
+ * still fires with just the message, exactly as it did before the hydration existed.
757
+ */
758
+ export type NameDropContextHydrator = () => Promise<WakeBackscrollLines | null>;
759
+ /** Dependencies for {@link routeNameDropEvent} — injected so the chain is unit-testable. */
760
+ export interface NameDropRouteDeps {
761
+ /** Is name-drop wake enabled for this channel's domain? (sync — config lookup). */
762
+ readonly enabledFor: (surfaceKey: string) => boolean;
763
+ /** Positively confirm the channel is internal (not externally shared). Fail-closed. */
764
+ readonly confirmInternal: (channelId: string) => Promise<boolean>;
765
+ /**
766
+ * Recent conversation backscroll for the wake (surrounding thread/channel history), as RAW
767
+ * {@link ContextLine}s + source + hint (W3) — renderWake wraps them via the shared renderContext,
768
+ * so wrapping happens in exactly one place instead of pre-rendered strings threading through the
769
+ * plumbing. Fetched only AFTER the gate decides deliver:true, so a declined message never pays the
770
+ * API call. Fail-soft: `null` (or an absent dep) = wake with just the message, exactly as before.
771
+ * Optional so existing test doubles keep compiling.
772
+ * `threadAdmissionSlice` asks the THREAD leg for its wider admission slice (OPE-1744): the
773
+ * hydrator sets it on the seat road, where it admits first and caps itself afterwards.
774
+ */
775
+ readonly backscroll?: (input: {
776
+ channelId: string;
777
+ ts: string;
778
+ threadTs?: string;
779
+ threadAdmissionSlice?: boolean;
780
+ }) => Promise<WakeBackscrollLines | null>;
781
+ /** The per-author membership probe the hydrated-context withhold uses (OPE-525,
782
+ * {@link withholdNameDropContext}). Defaults to the live {@link slackAuthorIsMember}; injectable
783
+ * so the withhold is testable without Slack. Never called on the shipped `taint` grain. */
784
+ readonly authorIsMember?: SlackMemberProbe;
785
+ /** Start the judged turn in a thread rooted at the triggering message. `context` (when present)
786
+ * is the backscroll — ADDITIONAL history the channel appends before the delivery turn. */
787
+ readonly wake: (input: {
788
+ channelId: string;
789
+ threadTs: string;
790
+ auth: unknown;
791
+ message: string;
792
+ context?: readonly string[];
793
+ state?: unknown;
794
+ }) => Promise<void>;
795
+ /**
796
+ * The bot's SLACK display name (cached lookup) — the agent's PUBLIC FACE: people type the
797
+ * name they see in the sidebar. It is ALSO the name the agent identifies as, unless the
798
+ * deployment sets the `identity.name` setting — that canonical name wins for
799
+ * self-identification on every surface, AND (OPE-571) joins this trigger's own token set
800
+ * alongside the display name (see {@link wakeNameDropTokens}), extendable/narrowable by
801
+ * `wake.nameDrop.include`/`wake.nameDrop.exclude`.
802
+ * Fail-soft: undefined (lookup failed) = no candidate at all = no name-drop wake, regardless
803
+ * of `identity.name` or the wake settings.
804
+ */
805
+ readonly displayName: () => Promise<string | undefined>;
806
+ /**
807
+ * WARM-ON-WAKE latch: engage the thread as a WATCHER (participating:false) right before
808
+ * the wake, so a later message rolls into THIS session even if this turn judges NO_REPLY.
809
+ * Best-effort + idempotent — never fails the wake. Optional so older test doubles keep
810
+ * compiling; live wiring passes markThreadEngaged.
811
+ */
812
+ readonly markEngaged?: (p: Partition, threadTs: string, atIso: string, opts?: {
813
+ participating?: boolean;
814
+ }) => Promise<void>;
815
+ /** Clock seam for the warm-latch timestamp (tests pass a fixed value). */
816
+ readonly now?: () => string;
817
+ /** Resolve the trigger author's display name for the envelope (fail-soft — absent/throwing ⇒
818
+ * raw id renders, the wake still fires). Live wiring: {@link fetchUserProfileNames}. */
819
+ readonly resolveProfile?: ResolveProfile;
820
+ /**
821
+ * 🔴 THE SEAT ROAD (OPE-1725). Present ⇔ this channel HAS a standing seat, and then every gate in
822
+ * this route is still the judge — the name match, the domain's `nameDropWake` opt-in, the
823
+ * fail-closed internal confirmation, the wake arm — while the WAKE ITSELF goes to that seat
824
+ * ({@link routeSupervisorEvent} with `nameDrop: true`) instead of opening eve's native per-thread
825
+ * session below.
826
+ * WHY THE SPLIT IS HERE AND NOT IN SLACK-FRONT: the candidacy decision is asynchronous (it needs
827
+ * the live display name) and it is this route's own, so deciding the road before it would mean a
828
+ * second copy of the judge. The seat route runs its own tenant fence, spend gate, external-policy
829
+ * check and trust resolution, so this route hands off BEFORE its own copies of those and never
830
+ * runs them twice.
831
+ * It is handed the HYDRATOR and nothing else. The seat wake renders no matched name (the standing
832
+ * identity line states the agent's own name every turn), so passing one would be a parameter
833
+ * nothing reads — but the surrounding-conversation hydration IS this route's own, and the seat
834
+ * wake must carry the same block, withheld the same way (see {@link NameDropContextHydrator}).
835
+ * Absent ⇒ the native per-thread wake below, which is what an unseated deployment keeps, byte for
836
+ * byte.
837
+ */
838
+ readonly seatRoute?: (hydrateContext: NameDropContextHydrator) => Promise<RouteOutcome>;
839
+ }
840
+ /**
841
+ * Route one TOP-LEVEL message as a name-drop candidate: pure checks, then the name
842
+ * regex, then config, then the (cached) internal-channel confirmation, then the wake.
843
+ * The reply thread is rooted at the triggering message's own ts — replying creates
844
+ * the thread, exactly like a human clicking "reply in thread".
845
+ */
846
+ export declare function routeNameDropEvent(ev: ParsedMessageEvent, deps: NameDropRouteDeps): Promise<RouteOutcome>;
847
+ export declare function botDisplayName(): Promise<string | undefined>;
848
+ /** Reset the cached display name (tests only). */
849
+ export declare function _resetBotDisplayName(): void;
850
+ /**
851
+ * Positively confirm a channel is internal (fail-closed, TTL-cached — see
852
+ * channelInternalAndMeta). Exported because the routine dispatcher
853
+ * (schedules/dispatch.ts) performs the same live recheck before stamping trust
854
+ * onto a fired session — one gate, two security consumers.
855
+ */
856
+ export declare function confirmChannelInternal(channelId: string): Promise<boolean>;
857
+ /**
858
+ * Positively confirm a channel is internal (fail-closed, same TTL-cached
859
+ * conversations.info as {@link confirmChannelInternal}) AND surface its member count —
860
+ * the count `confirmChannelInternal`'s boolean-only return discards. The T1.3 supervisor
861
+ * path needs both: the verdict gates the wake (fail-closed), the count enriches the wake
862
+ * envelope's `<channel member-count>`. One cached fetch serves both, so this is a widening
863
+ * of the existing gate, not a second API call.
864
+ */
865
+ export declare function confirmChannelInternalWithMeta(channelId: string): Promise<{
866
+ internal: boolean;
867
+ memberCount?: number;
868
+ }>;
869
+ /**
870
+ * Is this Slack author a member this deployment admits? `true` = a vouched member, `false` = a guest
871
+ * (multi-channel or single-channel), a Connect stranger, a deleted account, or someone from a
872
+ * workspace the operator has not declared. `undefined` = no verdict: an empty `userId` (nobody to
873
+ * ask about), no bot token, a transport failure, or a Slack `ok:false` body. Every caller must read
874
+ * `undefined` as "not proven to be a non-member" — see this section's fail-OPEN note.
875
+ *
876
+ * 🔴 "THE EXPECTED WORKSPACE" IS A RUNG WALK SINCE OPE-1508, not an equality. The event's own
877
+ * workspace is a member with no declaration needed; any OTHER workspace is a member only where the
878
+ * operator declared it, or the Slack Enterprise Grid above it — which is what admits a Grid
879
+ * colleague whose home `team_id` is a sibling workspace. A BOT author walks the same rungs: the
880
+ * workspace's own app is a member, a foreign app is a guest. `is_bot` no longer refutes on its own.
881
+ * `partnerFactsFromInfo` (slack-identity.ts) holds the whole rule and its reasoning.
882
+ *
883
+ * 🔴 AN ABSENT `teamId` IS A GUEST QUESTION, NOT A "COULD NOT ASK" (OPE-1579). A caller that names
884
+ * no workspace still gets a verdict, because the walk reads the author's own workspace and Grid off
885
+ * `users.info`: `true` follows the operator's declaration, `false` follows its absence. A missing
886
+ * workspace is therefore never a reason to answer `undefined` — that verdict belongs to the four
887
+ * causes listed above. `partnerFactsFromInfo` decides both this probe and the 1:1 DM gate, so the
888
+ * two readers cannot drift on what an unnamed workspace means.
889
+ *
890
+ * Keyed by `teamId` too, because `partnerFactsFromInfo` compares `team_id` against it: caching one
891
+ * verdict per user id alone would let a workspace-A answer decide a workspace-B question. The `/`
892
+ * separator cannot collide: a Slack team id and a Slack user id are both `[A-Z0-9]+`, so two
893
+ * different (team, user) pairs can never compose the same key. The operator's declarations are
894
+ * process-wide config, so they add no dimension to the key.
895
+ */
896
+ export declare function slackAuthorIsMember(userId: string, teamId: string | undefined): Promise<boolean | undefined>;
897
+ /** Reset the cached per-author membership verdicts (tests only). */
898
+ export declare function _resetAuthorMemberCache(): void;
899
+ /**
900
+ * {@link slackAuthorIsMember}, inverted to a GUEST verdict (OPE-589) — the ONE reading both the
901
+ * approval clicker floor's leg D and the HITL-answer admission's leg B reuse, so the two planes
902
+ * cannot drift on what "positively a guest" means for a per-person click. `undefined` stays
903
+ * `undefined`: "we could not ask" must never become a fabricated guest OR member verdict.
904
+ */
905
+ export declare function slackClickerIsGuest(userId: string, teamId: string | undefined): Promise<boolean | undefined>;
906
+ /**
907
+ * The SURFACE-INTERNAL fact a chat wake site should hand {@link resolveWakeTrust} for a CHANNEL
908
+ * message: the room is internal AND the author is not a positively-identified non-member.
909
+ *
910
+ * 🔴 IT NARROWS THE WAKE TIER ONLY, never the wake AUTH. The `internal` verdict also stamps
911
+ * `trustChecked`/`isExternal` onto the delivery auth, which is what gives the whole channel session
912
+ * its memory tier. A guest posting one message must not demote a channel's memory for everybody, so
913
+ * the auth keeps reading the ROOM's verdict and only the per-message trust tier reads this one.
914
+ *
915
+ * This is the same composition Discord and Teams already make one layer earlier: their per-author
916
+ * signals (`member.pending`, Entra `userType: guest`) demote `binding.trust`, which is what
917
+ * `resolveWakeTrustFromContext` reads as its surface verdict. Slack had no such fold because its
918
+ * probe was DM-only.
919
+ */
920
+ /** The probe as its two consumers below take it — injected ONLY so a unit test can drive the
921
+ * composition without a live `users.info`. Every live call site lets it default. */
922
+ export type SlackMemberProbe = (userId: string, teamId: string | undefined) => Promise<boolean | undefined>;
923
+ /**
924
+ * BOTH facts a chat wake site needs about its trigger author, from ONE probe call.
925
+ *
926
+ * `surfaceInternal` is the surface fact {@link resolveWakeTrust} takes: the room is internal AND the
927
+ * author is not a positively-identified non-member.
928
+ *
929
+ * 🔴 IT NARROWS THE WAKE TIER ONLY, never the wake AUTH. The `internal` verdict also stamps
930
+ * `trustChecked`/`isExternal` onto the delivery auth, which is what gives the whole channel session
931
+ * its memory tier. A guest posting one message must not demote a channel's memory for everybody, so
932
+ * the auth keeps reading the ROOM's verdict and only the per-message trust tier reads this one.
933
+ *
934
+ * `refuted` is the separate question the chat-taint join asks (access/taint-signal.ts `nonMemberChatTrust`): did the
935
+ * probe POSITIVELY say this person is not a member? The two are not the same predicate and must not
936
+ * be re-derived from each other — `surfaceInternal: false` is also what an EXTERNAL ROOM produces,
937
+ * and an external room is not evidence about the author at all.
938
+ *
939
+ * Deriving both here, from one verdict, is what stops them disagreeing. The probe is TTL-cached, so
940
+ * a second call would be cheap but a second READ would be a second chance to be inconsistent.
941
+ */
942
+ export declare function slackWakeAuthorFacts(channelInternal: boolean, userId: string | undefined, teamId: string | undefined, probe?: SlackMemberProbe): Promise<{
943
+ readonly surfaceInternal: boolean;
944
+ readonly refuted: boolean;
945
+ }>;
946
+ /**
947
+ * The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
948
+ * applied when the domain registry can be read. An unreadable registry degrades to the org default
949
+ * rather than throwing — the policy plane must never be the thing that breaks a wake.
950
+ */
951
+ export declare function slackExternalPolicyForRoom(roomKey: string): ExternalPolicy;
952
+ /**
953
+ * THE EXTERNAL-PARTICIPANT VERDICT for one Slack message — pure, so the four outcomes are tested
954
+ * without a Slack call, a store or a live workspace. The four values name WHAT THE CALLER OWES; the
955
+ * reasoning is stated once, on discord-gateway.ts's `DiscordExternalVerdict`.
956
+ *
957
+ * 🔴 THE AUTHOR GRAIN IS TESTED FIRST, AND THE ORDER MATTERS — an excluded author is owed a notice
958
+ * they can act on, where the room announcement tells the whole room something about the room.
959
+ */
960
+ export type SlackExternalVerdict = "proceed" | "withheld-addressed" | "withheld-ambient" | "silent-room";
961
+ export declare function slackExternalVerdict(input: {
962
+ readonly policy: ExternalPolicy;
963
+ readonly author: IngestedChatMessage;
964
+ /** The message @mentions the bot, or arrived in a DM — an ADDRESSED message is owed an answer. */
965
+ readonly addressed: boolean;
966
+ readonly isDm: boolean;
967
+ /** The SURFACE's vendor verdict — Slack Connect's `is_ext_shared`. Absent means "not reported",
968
+ * which includes "the probe could not answer": this leg fails OPEN. */
969
+ readonly vendorExternal?: boolean;
970
+ /** The DOMAIN-grain surface key (a thread asks its parent channel), for the room classification. */
971
+ readonly roomKey: string;
972
+ }): SlackExternalVerdict;
973
+ /**
974
+ * Tell an EXCLUDED sender, PRIVATELY, that the agent will not act on their message (behaviour 1).
975
+ * Best-effort. Answers whether the notice REACHED the sender.
976
+ *
977
+ * 🔴 THIS NOTICE NEVER GOES TO THE CHANNEL, AND THAT RULING STANDS. Its text names the organisation
978
+ * ("messages from outside this organisation"), so posting it where the room can read it would
979
+ * announce that this person is classified as an outsider — a disclosure about a member of the room,
980
+ * not a notice to the sender. If `conversations.open` fails (no token, the sender cannot be DM'd,
981
+ * Slack refuses), THIS notice is not said anywhere.
982
+ *
983
+ * 🔴 THE `false` RETURN IS LOAD-BEARING (OPE-885). It does not mean "say nothing at all" any more.
984
+ * An EXPLICITLY ADDRESSED wake that this notice could not reach falls back to the room's fixed
985
+ * `EXTERNAL_MENTION_ACK`, which is a different string precisely because it discloses nothing — see
986
+ * `applySlackExternalPolicy`. So the caller needs the truth about delivery, and every failure arm
987
+ * here answers `false` rather than swallowing into a silent `void`.
988
+ *
989
+ * A message that ARRIVED in a DM needs no open — it posts straight back into that conversation.
990
+ */
991
+ export declare function postSlackExternalSenderNotice(userId: string | undefined, dmChannelId?: string, seams?: {
992
+ readonly open?: (userId: string) => Promise<string | undefined>;
993
+ readonly post?: (channelId: string, text: string) => Promise<boolean>;
994
+ }): Promise<boolean>;
995
+ /**
996
+ * Post the SUPPRESSED-WAKE half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER
997
+ * ROOM, EVER. Slack had the JOIN half only ({@link announceSilentJoin}); this is its twin, and the
998
+ * pair is now the closed set `SILENT_NOTICE_KINDS` names.
999
+ *
1000
+ * FAIL-CLOSED on the claim, which is the inverse of `postOnce`'s posture and deliberate: a duplicate
1001
+ * here is not a lost message, it is exactly the repetition the pair exists to avoid.
1002
+ */
1003
+ export declare function announceSlackSilentRoom(org: string, surfaceKey: string, channelId: string, store?: {
1004
+ claimNotice: (org: string, key: string, at: string) => Promise<boolean>;
1005
+ }, post?: (channelId: string, text: string) => Promise<void>): Promise<boolean>;
1006
+ /** The one call each Slack wake path makes once its author evidence is in hand: decide, pay what the
1007
+ * verdict owes, and answer whether the path must stand down. Exported for slack-front.ts, whose DM
1008
+ * seat and native-mention forward are the two paths that do not live in this file. */
1009
+ export declare function applySlackExternalPolicy(ev: ParsedMessageEvent, p: Partition, facts: {
1010
+ readonly trust: WakeTrust;
1011
+ readonly refuted: boolean;
1012
+ }, route: string): Promise<SlackExternalVerdict>;
1013
+ /**
1014
+ * Withhold an EXCLUDED author's HYDRATED CONTEXT LINES on the name-drop wake (OPE-525) — the
1015
+ * side-channel slot `withholdWakeBodies` cannot fill on its own.
1016
+ *
1017
+ * 🔴 WHY THE TRANSFORM NEEDS HELP HERE. Its automatic leg reaches an author whose ENVELOPE message
1018
+ * it withheld, and reads their id off that message. A name-drop's hydration quotes people who wrote
1019
+ * nothing in the envelope at all, so there is no withheld message to read an id from. This resolves
1020
+ * the answer for each distinct hydrated sender and hands the transform the sync predicate it takes.
1021
+ *
1022
+ * 🔴 A POSITIVE REFUTATION ONLY, AND ONLY FOR A HUMAN SENDER. `probe` returning `undefined` (no
1023
+ * token, an API error) is NOT a refutation — absence of evidence, and this leg fails OPEN like every
1024
+ * other in the plane. A `bot` line carries a bot id, and a machine author is never excluded, so only
1025
+ * `senderType: "user"` lines are asked about.
1026
+ *
1027
+ * COSTS NOTHING ON THE SHIPPED GRAIN: `taint` returns the wake by reference before any probe runs.
1028
+ * On `withhold` it costs one TTL-cached `users.info` per DISTINCT hydrated author, resolved in
1029
+ * parallel, and the render budget caps the hydration at ten lines.
1030
+ */
1031
+ export declare function withholdNameDropContext(nw: NormalizedWake, policy: ExternalPolicy, teamId: string | undefined, probe?: SlackMemberProbe): Promise<NormalizedWake>;
1032
+ /**
1033
+ * Re-grade a first-wake BACKSCROLL bundle's per-message trust with the same per-author probe
1034
+ * (OPE-466). `backscrollTrustResolver` is SYNCHRONOUS and takes the ROOM's internal verdict, so every
1035
+ * sibling in a confirmed-internal channel resolved `peer` — including a guest's. This runs after the
1036
+ * fetch, where the author ids are in hand, and demotes only what the probe positively refutes.
1037
+ *
1038
+ * ONE ANSWER FOR BOTH CONSUMERS, and that is why it re-grades the array rather than computing a
1039
+ * side-channel boolean: the demoted tier reaches the RENDER (the model sees `trust="unauth"` on that
1040
+ * line, and the envelope's reference-only `<system-note>` appears) and the taint JOIN reads the same
1041
+ * field. A boolean computed beside the array would let the two disagree.
1042
+ *
1043
+ * ONLY EVER DOWN. `relay` (machine authors) and an already-`unauth` sibling are untouched, and
1044
+ * nothing is ever promoted. Distinct authors only — a bundle re-quoting one person costs one probe,
1045
+ * and the probe is TTL-cached on top of that. Fail-OPEN: an unresolvable author keeps its tier.
1046
+ */
1047
+ export declare function regradeSlackBackscrollTrust(messages: readonly WakeMessageInput[], teamId: string | undefined, probe?: SlackMemberProbe): Promise<readonly WakeMessageInput[]>;
1048
+ /**
1049
+ * Resolve + regrade PER-LINE trust for a name-drop wake's {@link ContextLine} backscroll (OPE-475)
1050
+ * — the ContextLine analogue of {@link regradeSlackBackscrollTrust}, converging the two backscroll
1051
+ * shapes on ONE trust story instead of a second mechanism. The same two steps, in the same order:
1052
+ *
1053
+ * 1. every `senderType==="user"` line starts at the tier {@link resolveWakeTrust} gives a
1054
+ * positively-confirmed-internal channel (peer, or principal for a domain admin) — the SAME
1055
+ * synchronous resolution {@link backscrollTrustResolver} runs for the graded WakeMessageInput
1056
+ * shape, just applied per ContextLine instead of per WakeMessageInput;
1057
+ * 2. the SAME per-author probe ({@link slackAuthorIsMember}) then demotes any REFUTED non-member
1058
+ * to `trust:"unauth", refuted:true` — set together on the SAME line the render reads
1059
+ * ({@link renderContext}'s `trust="unauth"` marker + reference-only note), so the render and
1060
+ * the taint join can never disagree about one line.
1061
+ *
1062
+ * `agent`/`bot`/`unknown` lines are left untouched (no `trust`, no `refuted`) — machine or
1063
+ * unattributable authors are not this leg's question, matching {@link backscrollTrustResolver}'s
1064
+ * own machine-author floor. FAIL-OPEN: an unresolvable probe (network error, no token) leaves a
1065
+ * line at its pre-probe tier — the SAME carriage rule the taint join itself uses, never a demotion
1066
+ * on an assumption.
1067
+ */
1068
+ export declare function regradeSlackContextLineTrust(lines: readonly ContextLine[], channelConfirmedInternal: boolean, channelKey: string | undefined, teamId: string | undefined, probe?: SlackMemberProbe): Promise<readonly ContextLine[]>;
1069
+ /**
1070
+ * The LIVE single-window mention decision for one Slack @mention — the Slack arm of the shared
1071
+ * seam (unified-window.ts {@link singleWindowMentionPolicy}), mirroring the GitHub reference
1072
+ * (github.ts shouldSuppressNativeMention / ambientDelivery). Consulted by BOTH of slack-front's
1073
+ * mention legs — the `app_mention` native-forward (suppress?) and its `message.channels` sibling
1074
+ * (keep as an addressed wake?) — so the two stay exactly complementary: one call shape, one
1075
+ * TTL-cached channel fact underneath.
1076
+ *
1077
+ * Inputs to the seam:
1078
+ * • `singleWindow` — does this surface have a STANDING SEAT (the domain grain's window bit)? Since
1079
+ * OPE-1695 that is every channel the bot is in, and the chattiness flag has no part in it: a
1080
+ * mention wakes the room's own seat whether or not ambient talk does. Any config error ⇒ false —
1081
+ * the native split, never a throw on the webhook path.
1082
+ * • `privacyOk` — Slack's POSITIVE privacy fact, by exact analogy to GitHub's
1083
+ * `repository.private === true` floor: the channel must be POSITIVELY CONFIRMED INTERNAL
1084
+ * (the same fail-closed TTL-cached conversations.info gate the supervisor wake + wake-trust
1085
+ * paths use — externally-shared/unconfirmable ⇒ false) AND be a real channel/group (the exact
1086
+ * surface {@link shouldSupervisorWake} accepts — an mpim/im mention must keep its native path
1087
+ * or suppressing it would gap, since the supervisor gate would drop the sibling).
1088
+ *
1089
+ * Fail-closed throughout: unknown privacy, config error, or API failure all yield today's
1090
+ * behaviour (no suppression, no addressed wake). The non-single-window fast path returns without
1091
+ * any Slack API call, so every ordinary workspace mention stays byte-identical AND call-free.
1092
+ */
1093
+ export declare function slackSingleWindowMentionPolicy(channelId: string, surfaceKey: string): Promise<SingleWindowMentionPolicy>;
1094
+ /**
1095
+ * The two facts the DM-FAMILY front route needs about a conversation it cannot classify from the
1096
+ * envelope: its CHANNEL TYPE (`channel` | `group` | `im` | `mpim`, `undefined` when undetermined) and
1097
+ * whether the trust gate POSITIVELY confirms it internal. OPE-351 stage 4.
1098
+ *
1099
+ * WHY IT EXISTS AT ALL: **a raw `app_mention` envelope carries no `channel_type`**, so the front route
1100
+ * cannot tell an @mention in a GROUP DM from an @mention in a channel — and the two now need opposite
1101
+ * routing (a group DM's mention must land in the mpim's own standing window; a channel's must not).
1102
+ * The internal bit rides along because it comes from the SAME response and the mpim wake needs it for
1103
+ * `resolveWakeTrust`: a group DM has no single "partner" to trust-check the way a 1:1 does, so the
1104
+ * surface's own confirmed-internal verdict is what stands in — the same substitution
1105
+ * {@link routeSupervisorEvent} makes for a channel.
1106
+ *
1107
+ * WHY AN API CALL IS ACCEPTABLE HERE, having been deliberately avoided in
1108
+ * {@link slackSingleWindowMentionPolicy}. That function's common-path cost is genuinely zero because
1109
+ * it short-circuits on a SYNC config read. This one cannot: the fact it needs lives only in Slack. But
1110
+ * it is the SAME TTL-cached `conversations.info` the trust gate, the supervisor wake and the join intro
1111
+ * already share, and — the point that settles it — eve's native mention path resolves the identical
1112
+ * fact per mention through `enrichVisibility` (channels/slack.ts) with NO cache at all. A cached read
1113
+ * at the front is therefore strictly cheaper than the uncached read the forward it replaces triggers.
1114
+ *
1115
+ * FAIL-SOFT, AND THE CALLER MUST READ THAT AS "keep today's routing". A workspace whose app predates
1116
+ * the `mpim:read` scope (added to the manifest at OPE-332) gets `missing_scope` here on every group-DM
1117
+ * lookup: this answers `{ internal: false }` with NO channelType, the mention forwards to the native
1118
+ * path exactly as it did before stage 4, and the caller logs the miss by name — degraded, not broken,
1119
+ * and never a crash on an absent field.
1120
+ *
1121
+ * `appCanRead` is the THIRD fact, and it is fail-CLOSED rather than fail-soft: see
1122
+ * {@link appCanReadConversation}. It answers "can this app read this room at all", which the
1123
+ * internal/external verdict does not — a public channel the app never joined is internal AND
1124
+ * unreadable. The shortcut route (slack-front.ts, OPE-1442) reads it to decide whether the target
1125
+ * message's FILES may be registered and fetched with the bot token.
1126
+ *
1127
+ * `extShared` and `trustChecked` are the RAW platform halves `internal` joins away (OPE-1486). The
1128
+ * group-DM wake stamps them onto its auth so `bindSlack` grades an mpim on the same two facts a
1129
+ * channel is graded on — which is what lets the member plane's G4b demote a Slack Connect group DM
1130
+ * out of a workspace-wide `internal` declaration. Both are undefined when the probe answered
1131
+ * nothing, and an undefined one stamps nothing: absence never demotes.
1132
+ */
1133
+ export declare function slackChannelFactsFor(channelId: string): Promise<{
1134
+ internal: boolean;
1135
+ channelType?: string;
1136
+ appCanRead: boolean;
1137
+ extShared?: boolean;
1138
+ trustChecked?: boolean;
1139
+ }>;
1140
+ /**
1141
+ * SYNC predicate: does this surface route its wakes through a standing seat's WINDOW (a solo
1142
+ * per-surface window, a named group, or a cross-surface one)? True ⇔ the supervisor grain uses a
1143
+ * window — the SAME fact {@link slackSingleWindowMentionPolicy}'s `singleWindow` reads, without the
1144
+ * async privacy floor. A seated surface's mentions divert into its seat and its thread follow-ups
1145
+ * ride the seat (never a native per-thread session), so slack-front uses this to keep an ENGAGED
1146
+ * thread on the supervisor path rather than misrouting it to routeMessageEvent (task #100). Any
1147
+ * config-load error ⇒ false (fail-safe to the native per-channel split).
1148
+ */
1149
+ export declare function slackDomainUsesUnifiedWindow(surfaceKey: string): boolean;
1150
+ export declare function slackForm(method: string, body: string, token?: string | undefined): Promise<Record<string, unknown> | undefined>;
1151
+ /**
1152
+ * Render a `conversations.history` response (newest-first, raw Slack JSON — probe,
1153
+ * never assume) as a compact oldest-first transcript, or undefined when there's
1154
+ * nothing readable. System subtypes (joins/renames/…) are noise and dropped; when
1155
+ * the block cap trims, it trims the OLDEST lines — the recent tail is what an intro
1156
+ * needs. Pure, so the shape is unit-testable without Slack.
1157
+ */
1158
+ export declare function transcriptFromHistory(history: unknown): string | undefined;
1159
+ export declare function fetchUserProfileNames(userId: string): Promise<{
1160
+ name?: string;
1161
+ handle?: string;
1162
+ } | undefined>;
1163
+ type RosterEntry = {
1164
+ nativeId: string;
1165
+ name?: string;
1166
+ handle?: string;
1167
+ };
1168
+ /** Test seam / operator escape: drop the memoized roster so the next search re-reads Slack. */
1169
+ export declare function _resetWorkspaceRosterCache(): void;
1170
+ /**
1171
+ * Every human account in the workspace, as `{nativeId, name, handle}` — fail-soft to `[]` on a
1172
+ * missing scope, an API error, or a throw. The CALLER does the matching.
1173
+ *
1174
+ * Memoized for {@link SLACK_ROSTER_TTL_MS} (see the section header for why that TTL and not the
1175
+ * process-lifetime memo the profile cache uses). Concurrent callers inside one window share ONE
1176
+ * in-flight walk, so a burst cannot multiply the Tier-2 calls the cache exists to collapse.
1177
+ */
1178
+ export declare function fetchWorkspaceRoster(): Promise<readonly RosterEntry[]>;
1179
+ export declare function fetchUserDisplayName(userId: string): Promise<string | undefined>;
1180
+ export declare function fetchChannelDisplayName(channelId: string): Promise<string | undefined>;
1181
+ /**
1182
+ * Live Slack roster for a governance channel's floor tripwire (OPE-110, access/audience-floor.ts).
1183
+ * Two fail-closed Slack reads: conversations.info (is_private + externally-shared bit) and
1184
+ * conversations.members (member ids). The bot's own user id is excluded so the agent doesn't count
1185
+ * as a non-admin member. FAIL CLOSED: any failed/absent lookup ⇒ `resolved: false` ⇒ the floor can
1186
+ * never arm. `hasGuest` uses the channel's externally-shared bit as the available external signal;
1187
+ * per-member guest classification rides the guest-detection work (design §2/§10 fragile-point #1) and
1188
+ * is a future tightening, not a loosening (an internal-only private channel already needs every
1189
+ * member on the roster to arm). Governance channels are small by config lint, so a single members
1190
+ * page suffices; a channel larger than one page fails closed on the (unset) cursor rather than
1191
+ * silently attesting a partial roster.
1192
+ */
1193
+ export declare function slackGovernanceRoster(channelId: string): Promise<RoomRoster | undefined>;
1194
+ /**
1195
+ * Register {@link slackGovernanceRoster} as the governance floor-tripwire member supplier (OPE-110).
1196
+ * Core owns the seam + the fail-closed default; this is the Slack arm, wired at boot from
1197
+ * register-adapters via the adapter-api barrel (the app never reaches into channels/ directly — the
1198
+ * boundary rule). v1 is Slack-only (design fragile-point #1: membership APIs are the ground truth,
1199
+ * Slack/Discord/Teams first); a non-slack channel key returns undefined ⇒ that governance channel
1200
+ * simply can't arm yet, never a false attest.
1201
+ */
1202
+ export declare function registerSlackGovernanceMembers(): void;
1203
+ /** Production deps for the name-drop route: domain config + live Slack confirmation. */
1204
+ export declare function liveNameDropDeps(send: WakeSend): NameDropRouteDeps;
1205
+ /** The reserved pseudo-thread sentinel for a supervisor generation — seeded into
1206
+ * `state.threadTs` (non-empty) to make eve's re-anchor structurally unreachable (amendment
1207
+ * A). Deliberately not a real Slack ts, so it can never collide with a thread token. */
1208
+ export declare function supervisorSentinel(generation: number): string;
1209
+ /** The reserved continuation token for a channel's supervisor session at a given generation:
1210
+ * `${channelId}:oe-sup:g<generation>` — built via {@link slackContinuationToken} so it shares
1211
+ * eve's exact `${channelId}:${threadTs}` namespacing with thread tokens (the threadTs part is
1212
+ * the sentinel, which no real thread can ever be). */
1213
+ export declare function supervisorToken(channelId: string, generation: number): string;
1214
+ /**
1215
+ * The FIFO serialization key for a channel's supervisor wakes (slack-front.ts
1216
+ * `scheduleUnownedWake`) — deliberately GENERATION-INDEPENDENT, unlike {@link supervisorToken}.
1217
+ * The FIFO's job is to serialize supervisor delivery for one channel, and that must hold ACROSS
1218
+ * a rotation boundary (T1.5): keying on the generation-bearing token would split one channel's
1219
+ * serialized queue in two the instant a rotation minted a new generation, letting a pre- and
1220
+ * post-rotation wake run concurrently. Keying on the channel alone fixes that AND needs no store
1221
+ * read on the hot path (the earlier hardcode assumed generation was always 1 pre-rotation — see
1222
+ * the T1.3 comment this replaces).
1223
+ */
1224
+ export declare function supervisorFifoKey(channelId: string): string;
1225
+ /** Inputs to the pure supervisor-wake gate — staged by {@link routeSupervisorEvent}. */
1226
+ export interface SupervisorWakeInput {
1227
+ readonly authorIsBot: boolean;
1228
+ readonly isAppMention: boolean;
1229
+ readonly subtype?: string;
1230
+ readonly channelType?: string;
1231
+ readonly channelConfirmedInternal: boolean;
1232
+ }
1233
+ /**
1234
+ * The supervisor-wake gate. UNLIKE {@link shouldNameDropWake} there is NO name-regex needle —
1235
+ * the supervisor judges EVERY unowned message (always-on teammate). Disqualifiers, in cost
1236
+ * order: a non-edit subtype (system messages, and the bot's own posts arrive as subtypeless
1237
+ * `bot_message`s caught by authorIsBot) → a bot author (relay/`trust="relay"` is Phase 2, so
1238
+ * bot-authored still DROPS here) → an @mention (eve's own turn owns it) → non-channel/group
1239
+ * surfaces → the fail-closed internal-channel confirmation. A `message_changed` edit is KEPT
1240
+ * (the supervisor should notice edits — it wakes with `reason="message-edited"`).
1241
+ */
1242
+ export declare function shouldSupervisorWake(input: SupervisorWakeInput): ReplyDecision;
1243
+ /** Dependencies for {@link routeSupervisorEvent} — injected so the chain is unit-testable. */
1244
+ export interface SupervisorRouteDeps {
1245
+ /** Positively confirm the channel is internal (fail-closed) AND surface its member count,
1246
+ * one cached conversations.info. Unconfirmable/external → { internal: false }. */
1247
+ readonly confirmInternalWithMeta: (channelId: string) => Promise<{
1248
+ internal: boolean;
1249
+ memberCount?: number;
1250
+ }>;
1251
+ /**
1252
+ * Create-or-fetch the live supervisor session for (org, surfaceKey). Called ONLY after the
1253
+ * internal confirmation passes, so an external channel never mints a registry row. Three
1254
+ * outcomes, all reported via `freshGeneration`: (1) no row yet → mint generation 1; (2) an
1255
+ * existing row that {@link shouldRotate} says has aged out (expiry, quiet-gap or a backstop) →
1256
+ * rotate to generation+1; (3) an existing row that's still current → returned as-is.
1257
+ * `freshGeneration` is true for (1) and (2) — a BRAND-NEW OR JUST-ROTATED generation's first
1258
+ * wake carries channel backscroll (Tag §4.1); false for (3), an ongoing generation's later
1259
+ * wake, which carries none.
1260
+ */
1261
+ readonly getOrCreateSession: (org: string, surfaceKey: string, channelId: string, atIso: string) => Promise<{
1262
+ token: string;
1263
+ generation: number;
1264
+ freshGeneration: boolean;
1265
+ tail: readonly WakeMessageInput[];
1266
+ /** ISO-8601 of the most recent wake this generation delivered (the registry row's
1267
+ * `lastWakeAt`), or null on a freshly minted/rotated generation. The lower bound of the
1268
+ * since-last-wake catch-up read (OPE-1695 (b)); absent ⇒ the bootstrap window. */
1269
+ lastWakeAt?: string | null;
1270
+ }>;
1271
+ /** Record one wake on the current generation (lastWakeAt/turnCount). Best-effort. */
1272
+ readonly touchSession?: (org: string, surfaceKey: string, atIso: string) => Promise<void>;
1273
+ /**
1274
+ * Fetch the seat's channel backscroll as envelope siblings (fetchWakeEnvelopeBackscroll). ONE
1275
+ * read: a FRESH generation's BOOTSTRAP window. It used to serve the since-last-wake catch-up too,
1276
+ * through the `sinceMs` arm below; OPE-1726 moved that read to {@link catchUp} and the shared core
1277
+ * path, so no caller sets `sinceMs` any more. Optional so older test doubles keep compiling
1278
+ * (absent ⇒ no backscroll, same as an empty result); fail-soft by construction (the live impl
1279
+ * never throws).
1280
+ */
1281
+ readonly fetchBackscroll?: (input: {
1282
+ channelId: string;
1283
+ ts: string;
1284
+ /** The route's confirmInternal verdict for this surface — the SAME fact the trigger trust
1285
+ * uses (F3). The supervisor fetch only runs when internal, so it's true here; passing it
1286
+ * keeps the backscroll resolver deriving from the caller's real verdict, not a hardcode. */
1287
+ surfaceInternal: boolean;
1288
+ /** Domain-grain channel key (`platform/workspace/surface`) so a DOMAIN-admin backscroll author
1289
+ * resolves `principal`, not just org-roster admins (matches the trigger trust, which has it).
1290
+ * It also selects the domain's ADMISSION policy in the live wiring. */
1291
+ channelKey?: string;
1292
+ /** 🔴 DEAD since OPE-1726 — no caller sets it, and the live impl's bootstrap arm is the only one
1293
+ * reached. It was the catch-up's lower bound when Slack owned a private copy of that read; the
1294
+ * read now runs through {@link catchUp} and core `seatCatchUp`. Kept only so an older test
1295
+ * double that still passes it keeps compiling. Do NOT build a new catch-up on it: a window
1296
+ * fetched here bypasses the shared bounds, the domain's admission policy and the token cap. */
1297
+ sinceMs?: number;
1298
+ }) => Promise<readonly WakeMessageInput[]>;
1299
+ /**
1300
+ * THE SINCE-LAST-WAKE CATCH-UP, through the ONE shared path every surface now takes
1301
+ * (channels/seat-catch-up.ts `seatCatchUp`). It replaces the `sinceMs` arm of
1302
+ * {@link fetchBackscroll}, which was Slack's private copy of a read five other surfaces did not
1303
+ * have: the bounds, the domain's admission policy and the token cap live in core, and Slack
1304
+ * contributes only its registered fetch.
1305
+ * Optional so older test doubles keep compiling (absent ⇒ no catch-up, which is what an ongoing
1306
+ * generation's wake carried before OPE-1695). Fail-soft by construction.
1307
+ */
1308
+ readonly catchUp?: (req: SeatCatchUpRequest) => Promise<SeatCatchUpResult>;
1309
+ /** The per-author membership probe the NAME-DROP hydration's withhold uses (OPE-525,
1310
+ * {@link withholdNameDropContext}) — the same dep, spelled the same way, as the native route it
1311
+ * replaces ({@link NameDropRouteDeps.authorIsMember}). Defaults to the live
1312
+ * {@link slackAuthorIsMember}; injectable so the withhold is testable without Slack. Never called
1313
+ * on the shipped `taint` grain, and never on a wake that carries no hydrated lines. */
1314
+ readonly authorIsMember?: SlackMemberProbe;
1315
+ /**
1316
+ * Start/continue the supervisor session under its reserved token. `window` is present ONLY on
1317
+ * the SINGLE-WINDOW unified path (unifiedMeta reported `singleWindow` — channels/
1318
+ * unified-window.ts): the wakeId already rendered into this wake's envelope plus the wake's
1319
+ * reply-target origin, which the live wiring registers in the durable map by DIVERTING the
1320
+ * wake through eve's cross-channel `receive` into the synthetic `unified` channel instead of
1321
+ * this channel's own `send`. Absent ⇒ today's send path, byte-identical.
1322
+ */
1323
+ readonly wake: (input: {
1324
+ token: string;
1325
+ auth: unknown;
1326
+ message: string;
1327
+ /** The untrusted-wrapped side-channel context block(s) — the NAME-DROP hydration this seat wake
1328
+ * carries (OPE-1725), already withheld. Omitted on every other seat wake, so the payload stays
1329
+ * message-only and byte-identical to today. */
1330
+ context?: readonly string[];
1331
+ state: unknown;
1332
+ window?: UnifiedWindowWake;
1333
+ }) => Promise<void>;
1334
+ /** Append a durable audit row (e.g. `engagement.stop`). Optional; best-effort. */
1335
+ readonly audit?: (action: string, detail: Record<string, unknown>) => Promise<void>;
1336
+ /**
1337
+ * ENGAGE the trigger's thread as a PARTICIPATING surface (task #100 — window-riding stop parity):
1338
+ * called ONLY on an ADDRESSED `directMention` so a mention diverted into the window enrolls its
1339
+ * thread exactly as the native path does (slack.ts onInbound `markThreadEngaged`) and the Discord
1340
+ * divert does (divertDiscordWakeToUnifiedWindow, commit d4d6a17). That row is what the stop leg
1341
+ * ({@link getEngagement}) reads on a later bare "stop". Optional + best-effort; absent ⇒ no enroll.
1342
+ */
1343
+ readonly markEngaged?: (p: Partition, threadTs: string, atIso: string, opts?: {
1344
+ participating?: boolean;
1345
+ }) => Promise<void>;
1346
+ /** Read the trigger thread's engagement record — the ONE read shared by the stop leg AND the
1347
+ * stopped-suppression gate (task #100 + follow-up). A stop only acks a thread the supervisor is
1348
+ * participating in; a NON-mention message in a `stopped` thread is dropped before any wake.
1349
+ * Optional so older test doubles keep compiling (absent ⇒ neither leg fires); live wiring passes
1350
+ * the real store call. */
1351
+ readonly getEngagement?: (p: Partition, threadTs: string) => Promise<EngagementRow | null>;
1352
+ /** Mark the trigger thread STOPPED (task #100 follow-up stop leg). On the window path a stop
1353
+ * must NOT delete the row — the durable `stopped` marker is what the suppression gate reads to
1354
+ * drop later non-mention wakes (a deleted row suppresses nothing here). Sets the flag, keeps the
1355
+ * row. Optional; best-effort. (Discord keeps DELETing via its own path — see disengageThread.) */
1356
+ readonly markStopped?: (p: Partition, threadTs: string) => Promise<boolean>;
1357
+ /** Clock seam (defaults to the real clock) — the createdAt/lastWakeAt + envelope current-time. */
1358
+ readonly now?: () => string;
1359
+ /** Resolve trigger + backscroll author display names for the envelope (fail-soft — absent or
1360
+ * throwing ⇒ raw ids render, the wake still fires). Live wiring: {@link fetchUserProfileNames}. */
1361
+ readonly resolveProfile?: ResolveProfile;
1362
+ /** Resolve the surface's engagement calibration tier (memory/domain.ts engagementTierFor) for
1363
+ * the channel-watch preamble. Optional + fail-soft: absent or throwing ⇒ neutral (the
1364
+ * pre-tier byte-identical preamble). Live wiring reads the loaded domain config. */
1365
+ readonly engagementTier?: (surfaceKey: string) => WakeEngagementTier;
1366
+ /**
1367
+ * UNIFIED cross-surface supervisor grain for this channel (channels/unified-supervisor.ts). Absent
1368
+ * or `{ unified: false }` ⇒ the per-container path, byte-identical to today. `{ unified: true }` ⇒
1369
+ * the wake is stamped with its `<channel platform>` address + the P5 disclosure preamble. This dep
1370
+ * controls only the WAKE render; the registry-row/token re-key lives inside getOrCreateSession /
1371
+ * touchSession (which receive the surfaceKey and resolve the same grain). Optional +
1372
+ * fail-soft: absent or throwing ⇒ not unified (the byte-identical path). Live wiring reads the
1373
+ * loaded domain config.
1374
+ */
1375
+ readonly unifiedMeta?: (surfaceKey: string) => {
1376
+ unified: boolean;
1377
+ label?: string;
1378
+ singleWindow?: boolean;
1379
+ };
1380
+ }
1381
+ /**
1382
+ * Route one unowned message to the channel supervisor: pure disqualifiers, then the
1383
+ * fail-closed internal-channel confirmation (which also surfaces the member count for the
1384
+ * envelope), then create-or-fetch the supervisor session and deliver the judged wake. The
1385
+ * caller (slack-front) only reaches here once the domain has opted into ambientSupervisor.
1386
+ *
1387
+ * `opts.directMention` (SINGLE-WINDOW @mention routing — the GitHub reference's
1388
+ * `treatMentionAsDirect`/`directMention` shape): the trigger is the `message.channels` SIBLING of
1389
+ * an `app_mention` that slack-front kept because {@link slackSingleWindowMentionPolicy} said the
1390
+ * unified window owns it (native forward suppressed). The wake then renders as an ADDRESSED
1391
+ * `direct-mention` (envelope + wake-id kept, silence bias dropped) and the minted window carries
1392
+ * `addressing:"direct-mention"` so the unified channel arms the reply bridge. The final say stays
1393
+ * with the SAME shared seam ({@link singleWindowMentionPolicy}) re-checked here against this
1394
+ * route's own singleWindow + internal facts — a directMention flag on a non-single-window or
1395
+ * non-internal wake degrades safely to the plain channel-watch treatment. Unset ⇒ byte-identical.
1396
+ */
1397
+ export declare function routeSupervisorEvent(ev: ParsedMessageEvent, deps: SupervisorRouteDeps, opts?: {
1398
+ readonly directMention?: boolean;
1399
+ readonly nameDrop?: boolean;
1400
+ /**
1401
+ * THE NAME-DROP HYDRATION, HANDED ACROSS THE SEAT HAND-OFF (OPE-1725) — see
1402
+ * {@link NameDropContextHydrator}. Read only on the `nameDrop` tier, and only AFTER this route's
1403
+ * own tenant fence, spend gate and external-participant policy have admitted the wake, so a
1404
+ * refused seat wake buys no Slack page. Absent ⇒ no side-channel context block, which is every
1405
+ * other seat wake.
1406
+ */
1407
+ readonly nameDropContext?: NameDropContextHydrator;
1408
+ }): Promise<RouteOutcome>;
1409
+ /**
1410
+ * Production deps for the supervisor route: the fail-closed internal+meta gate, a
1411
+ * create-or-fetch over the channel_session registry (generation 1 on first mint; rotates to
1412
+ * generation+1 when {@link shouldRotate} says the current generation has aged out — T1.5), a
1413
+ * best-effort touch, the live time-windowed first-wake backscroll fetcher, and a wake that
1414
+ * continues the session under its reserved token.
1415
+ */
1416
+ export declare function liveSupervisorDeps(send: WakeSend): SupervisorRouteDeps;
1417
+ /** The per-turn reply target snapshotted at `turn.started` from the delivery's auth attributes
1418
+ * (`channel_id`/`thread_ts`). `threadTs` present ⇔ the triggering message was itself in a
1419
+ * thread; absent ⇔ it was a top-level channel message. */
1420
+ export interface SupervisorReplyTarget {
1421
+ readonly channelId?: string;
1422
+ readonly threadTs?: string;
1423
+ }
1424
+ /** The pure post plan for a supervisor turn. `post: null` = fail safe (no resolvable target →
1425
+ * post NOTHING). `bindThread` = the reply lands in a real thread and should mark it engaged
1426
+ * under the supervisor as owner (Tag "the channel session answers a cheap thread and now owns
1427
+ * it"); false for a top-level reply (binds nothing). */
1428
+ export interface SupervisorReplyPlan {
1429
+ readonly post: {
1430
+ readonly channel: string;
1431
+ readonly threadTs?: string;
1432
+ } | null;
1433
+ readonly bindThread: boolean;
1434
+ readonly reason: string;
1435
+ }
1436
+ /**
1437
+ * Pure: resolve WHERE a supervisor turn's reply posts, from the per-turn target snapshot.
1438
+ * FAIL SAFE: a missing/empty `channelId` yields `post: null` — the caller posts nothing rather
1439
+ * than guessing a target. A non-empty `threadTs` means the trigger was threaded ⇒ post into that
1440
+ * thread AND bind it; absent ⇒ a top-level channel post with no `thread_ts` and no bind.
1441
+ */
1442
+ export declare function planSupervisorReply(target: SupervisorReplyTarget | undefined): SupervisorReplyPlan;
1443
+ /** Pure gate: is THIS turn a supervisor turn? The supervisor host-side reply branch (and the
1444
+ * `turn.started` target snapshot) fire ONLY when this is true, so a non-supervisor turn takes
1445
+ * eve's default reply path byte-identically. `oeSupervisor` is seeded by T1.3's wake `state`. */
1446
+ export declare function isSupervisorReplyTurn(state: unknown): boolean;
1447
+ /** Pure: the per-wake assistant-thread OVERRIDE for a DM-seat turn, or undefined when there is
1448
+ * none. Returns the snapshotted wake thread (`oeWakeThreadTs`) ONLY when it is present AND differs
1449
+ * from the session's persisted `threadTs` (the frozen seat anchor). Undefined for every non-DM
1450
+ * turn (`oeWakeThreadTs` unset), a DM whose wake IS the session thread (its first wake), and a
1451
+ * blank snapshot — every such case falls back to `state.threadTs`, byte-identical to pre-OPE-98. */
1452
+ export declare function dmWakeThreadOverride(state: unknown): string | undefined;
1453
+ /** Pure: the Slack thread a turn's reply + thinking-status must target, from the turn's channel
1454
+ * state. The DM-seat per-wake override wins; otherwise the session's persisted `state.threadTs`
1455
+ * (undefined ⇒ a top-level post with no `thread_ts`). This is the ONE targeting rule both the reply
1456
+ * post and the assistant-status set/clear read, so on a DM seat they can never diverge onto
1457
+ * different threads (the OPE-98 failure: reply on one thread, spinner stuck on another). */
1458
+ export declare function replyThreadTsForState(state: unknown): string | undefined;
1459
+ /** Dependencies injected into {@link deliverSupervisorReply} so the dispatch is testable without
1460
+ * Slack or a live runtime. `post` = the host-side `slackForm("chat.postMessage")` seam;
1461
+ * `resolveOwnerToken` = the supervisor's registry token (T1.3) for thread binding; `bind` =
1462
+ * `markThreadEngaged(…, { participating: true, ownerToken })`. */
1463
+ export interface SupervisorReplyDeps {
1464
+ /** Whether the turn's text should actually post (false when a card already narrated it this
1465
+ * turn — the default path's `postText` gate; the reply still counts as participation). */
1466
+ readonly postText: boolean;
1467
+ /** The reply body (only posted when `postText`). */
1468
+ readonly text: string;
1469
+ /** The per-turn target snapshot from `channel.state.oeReplyTarget`. */
1470
+ readonly target: SupervisorReplyTarget | undefined;
1471
+ /** Host-side post: `slackForm("chat.postMessage", { channel, thread_ts?, text })`. */
1472
+ readonly post: (channelId: string, threadTs: string | undefined, text: string) => Promise<void>;
1473
+ /** The supervisor session's reserved token (the ownerToken for a bound thread), or undefined
1474
+ * when it can't be resolved — in which case the thread is left unbound (the reply still posts). */
1475
+ readonly resolveOwnerToken: () => Promise<string | undefined>;
1476
+ /** Mark the replied-into thread engaged with the supervisor as owner. */
1477
+ readonly bind: (threadTs: string, ownerToken: string) => Promise<void>;
1478
+ }
1479
+ export interface SupervisorReplyResult {
1480
+ readonly posted: boolean;
1481
+ readonly bound: boolean;
1482
+ readonly reason: string;
1483
+ }
1484
+ /**
1485
+ * Dispatch a supervisor turn's reply host-side (NEVER via eve's `channel.thread.post`). Resolves
1486
+ * the target with {@link planSupervisorReply}: no target ⇒ posts nothing (fail safe); a top-level
1487
+ * target ⇒ posts to the channel with NO `thread_ts`; a threaded target ⇒ posts into the thread
1488
+ * AND binds it to the supervisor (ownerToken = the supervisor's registry token). Binding is
1489
+ * independent of `postText` — a card-narrated reply still participated, so it still owns the thread.
1490
+ */
1491
+ export declare function deliverSupervisorReply(deps: SupervisorReplyDeps): Promise<SupervisorReplyResult>;
1492
+ /** A Slack `member_joined_channel` event narrowed to what the intro path needs. */
1493
+ export interface ParsedChannelJoinEvent {
1494
+ readonly channelId: string;
1495
+ /** The join event's ts — the "X joined" notice the intro threads under. */
1496
+ readonly ts: string;
1497
+ readonly teamId?: string;
1498
+ /** The Enterprise Grid organisation the join happened in ({@link slackEnterpriseIdFromEnvelope}) —
1499
+ * carried so the intro turn's binding walks the SAME rungs a mention in that channel walks. */
1500
+ readonly enterpriseId?: string;
1501
+ /** Who joined. The front route only proceeds when this is the bot's OWN user id. */
1502
+ readonly userId: string;
1503
+ /** Who invited them, when Slack says (the human actor behind the join). */
1504
+ readonly inviterId?: string;
1505
+ /** member_joined_channel's own vocabulary: "C" (public channel) | "G" (private). */
1506
+ readonly channelKind?: string;
1507
+ }
1508
+ /**
1509
+ * Parse a `member_joined_channel` envelope into {@link ParsedChannelJoinEvent}, or null
1510
+ * when it isn't one (wrong outer/event type, missing ids). Pure — no I/O.
1511
+ */
1512
+ export declare function parseChannelJoinEvent(envelope: SlackEventEnvelope): ParsedChannelJoinEvent | null;
1513
+ /** The BindableContext a join event implies — same shape {@link bindableFor} builds. */
1514
+ export declare function bindableForJoin(ev: ParsedChannelJoinEvent): BindableContext;
1515
+ /** The channel metadata the join dispatch classifies from — one conversations.info. */
1516
+ export interface ChannelJoinMeta {
1517
+ readonly channelId: string;
1518
+ readonly name?: string;
1519
+ readonly topic?: string;
1520
+ readonly purpose?: string;
1521
+ readonly memberCount?: number;
1522
+ readonly channelType?: string;
1523
+ readonly isPrivate?: boolean;
1524
+ /**
1525
+ * The VENDOR's own external verdict for this channel (OPE-504) — `conversations.info` reported
1526
+ * `is_ext_shared` / `is_org_shared` / `is_shared` / `is_pending_ext_shared`, i.e. a Slack Connect
1527
+ * or otherwise shared channel that can hold people from another organisation.
1528
+ *
1529
+ * 🔴 IT IS TRUE ONLY ON A SUCCESSFUL PROBE THAT SAID YES. `channelFactsFromInfo` fail-softs an
1530
+ * unreadable response to no facts at all, and this field stays absent there rather than becoming
1531
+ * `true` — "we could not ask" is not "we asked and it is external". Absent therefore means "not
1532
+ * reported", which the external-room classification reads as no evidence (fail-open), exactly like
1533
+ * every other carriage leg in that plane.
1534
+ */
1535
+ readonly vendorExternal?: boolean;
1536
+ }
1537
+ /**
1538
+ * Extract {@link ChannelJoinMeta} from a `conversations.info` response. Defensive like
1539
+ * {@link channelFactsFromInfo} (raw Slack JSON — probe, never assume); a failed lookup
1540
+ * yields metadata-less meta, and the dispatch degrades to "(unknown)" fields.
1541
+ */
1542
+ export declare function channelJoinMetaFromInfo(channelId: string, info: unknown): ChannelJoinMeta;
1543
+ /** The read-up context the route gathers before the intro wake (all fail-soft). */
1544
+ export interface ChannelJoinContext {
1545
+ /** Compact oldest-first backscroll of the channel ({@link fetchChannelTranscript}). */
1546
+ readonly transcript?: string;
1547
+ /** Display name of whoever invited the bot. */
1548
+ readonly inviterName?: string;
1549
+ }
1550
+ /** The route-posted ack that covers the read+judge latency (Tag's "reading up" reply). */
1551
+ export declare const JOIN_ACK_TEXT = "\uD83D\uDC4B Just joined \u2014 reading up on this channel; back in a moment.";
1552
+ export declare function renderChannelJoinMessage(meta: ChannelJoinMeta, context?: ChannelJoinContext): string;
1553
+ /** Dependencies for {@link routeChannelJoinEvent} — injected so the chain is unit-testable. */
1554
+ export interface ChannelJoinRouteDeps {
1555
+ /** Has this surface already been introduced? (shared marker — onboarding/onboarding.ts). */
1556
+ readonly isOnboarded: (p: ReturnType<typeof resolvePartition>) => Promise<boolean>;
1557
+ /** Confirm internal + hand back the metadata from the SAME conversations.info. Fail-closed. */
1558
+ readonly confirmInternal: (channelId: string) => Promise<{
1559
+ internal: boolean;
1560
+ meta?: ChannelJoinMeta;
1561
+ }>;
1562
+ /** Start the judged intro turn, threaded under the join notice. */
1563
+ readonly wake: (input: {
1564
+ channelId: string;
1565
+ threadTs: string;
1566
+ auth: unknown;
1567
+ message: string;
1568
+ state?: unknown;
1569
+ }) => Promise<void>;
1570
+ /** Claim the surface as onboarded BEFORE the wake (the double-intro race gate). */
1571
+ readonly markOnboarded: (p: ReturnType<typeof resolvePartition>) => Promise<void>;
1572
+ /** The channel's recent backscroll as a compact transcript (fail-soft undefined). */
1573
+ readonly transcript: (channelId: string) => Promise<string | undefined>;
1574
+ /** A user's display name, for "invited by" (fail-soft undefined). */
1575
+ readonly userName: (userId: string) => Promise<string | undefined>;
1576
+ /** Post the immediate "reading up" ack under the join notice (fail-soft). */
1577
+ readonly ack: (channelId: string, threadTs: string, text: string) => Promise<void>;
1578
+ }
1579
+ /**
1580
+ * Post the JOIN half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER ROOM, EVER.
1581
+ *
1582
+ * The claim (`claimNotice`) is a permanent row, not a TTL and not a process-local flag: a re-invite,
1583
+ * a redeploy or a cold start must not make the agent say it again. Fail-CLOSED on the claim — if the
1584
+ * store cannot prove the notice is unsaid, the agent stays quiet, because a repeat here is exactly
1585
+ * the periodic announcement the design rejects.
1586
+ *
1587
+ * Exported for the test, which is the only way to drive it without a live Slack.
1588
+ */
1589
+ export declare function announceSilentJoin(ev: ParsedChannelJoinEvent, p: ReturnType<typeof resolvePartition>, meta: ChannelJoinMeta | undefined, deps: Pick<ChannelJoinRouteDeps, "ack">, store?: {
1590
+ claimNotice: (org: string, key: string, at: string) => Promise<boolean>;
1591
+ }): Promise<void>;
1592
+ /**
1593
+ * Route one bot-join event: once-only check, then the fail-closed internal confirmation,
1594
+ * then a judged intro wake threaded under the join notice. The caller (slack-front.ts)
1595
+ * has already verified the joiner IS the bot and de-duped the event.
1596
+ */
1597
+ export declare function routeChannelJoinEvent(ev: ParsedChannelJoinEvent, deps: ChannelJoinRouteDeps): Promise<RouteOutcome>;
1598
+ /** A Slack reaction_added / reaction_removed event, narrowed to the fields ingestion needs. */
1599
+ export interface ParsedSlackReaction {
1600
+ readonly verb: ReactionVerb;
1601
+ /** WHO reacted. */
1602
+ readonly reactorId: string;
1603
+ /** The bare Slack emoji name (may carry a `::skin-tone-N` suffix — the classifier strips it). */
1604
+ readonly emoji: string;
1605
+ /** The reacted message's channel + ts (`item.channel` / `item.ts`). */
1606
+ readonly channelId: string;
1607
+ readonly messageTs: string;
1608
+ /** The AUTHOR of the reacted message (`item_user`), when Slack supplies it — used to
1609
+ * decide onBotReply against the bot's own user id. */
1610
+ readonly itemUser?: string;
1611
+ readonly teamId?: string;
1612
+ }
1613
+ /**
1614
+ * Parse a Slack reaction envelope into {@link ParsedSlackReaction}, or null when it isn't a
1615
+ * usable reaction on a message (wrong outer/event type, a non-`message` item, missing ids).
1616
+ * Pure — no I/O — so it's trivially testable against fixtures. Note Slack only carries
1617
+ * `item.channel`/`item.ts` for `item.type === "message"` (file/comment reactions have a
1618
+ * different item shape and no channel/ts — dropped here as not-a-message-reaction).
1619
+ */
1620
+ export declare function parseSlackReactionEvent(envelope: SlackEventEnvelope): ParsedSlackReaction | null;
1621
+ /** The BindableContext a reaction implies — same shape {@link bindableFor} builds, but from
1622
+ * the reaction's own coordinates (a reaction event carries no channel_type). */
1623
+ export declare function bindableForReaction(ev: ParsedSlackReaction): BindableContext;
1624
+ /** Dependencies for {@link routeReactionEvent} — injected so parse+route+record is unit-tested
1625
+ * without a live store or Slack. `botIdentity` is the ALREADY-RESOLVED identity (the front
1626
+ * route resolves it once, as it does for the wake/join paths — passing a value, not a lookup,
1627
+ * keeps this module free of a slack-front.ts import cycle). */
1628
+ export interface SlackReactionRouteDeps {
1629
+ readonly botIdentity: {
1630
+ readonly userId?: string;
1631
+ readonly botId?: string;
1632
+ };
1633
+ readonly sink: ReactionSinkDeps;
1634
+ /** eve's I/O-free cancel door, present only when the front route handed one in (the ✋ stop arm,
1635
+ * OPE-418). Absent ⇒ a ✋ is ingested as an ordinary reaction and nothing is cancelled. */
1636
+ readonly attachSession?: AttachSessionForCancel;
1637
+ }
1638
+ /**
1639
+ * Route one parsed Slack reaction: drop the bot's OWN reaction (its 👀/status reactions are
1640
+ * not feedback), resolve whether the target is the bot's own message (item_user === bot),
1641
+ * then hand the surface-neutral {@link ingestReaction} the parsed reaction. Returns the
1642
+ * ingest outcome (recorded / telemetry-only / ignored). All side effects go through the
1643
+ * injected sink.
1644
+ */
1645
+ export declare function routeReactionEvent(ev: ParsedSlackReaction, deps: SlackReactionRouteDeps): Promise<ReactionIngestOutcome>;
1646
+ /** Production deps for the reaction route: the resolved bot identity + the live audit/telemetry
1647
+ * sink. `botIdentity` is passed in by the front route (which owns the cached auth.test lookup). */
1648
+ export declare function liveReactionDeps(botIdentity: {
1649
+ readonly userId?: string;
1650
+ readonly botId?: string;
1651
+ }, attachSession?: AttachSessionForCancel): SlackReactionRouteDeps;
1652
+ /** Production deps for the channel-join route: the shared marker + live Slack confirmation. */
1653
+ export declare function liveChannelJoinDeps(send: WakeSend): ChannelJoinRouteDeps;
1654
+ //# sourceMappingURL=slack-events.d.ts.map