@open-everywhere/provider-teams 0.0.0-stage → 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 (146) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +46 -2
  4. package/agent/channels/teams-bot-name.ts +162 -0
  5. package/agent/channels/teams-catch-up.ts +244 -0
  6. package/agent/channels/teams-connector.ts +183 -0
  7. package/agent/channels/teams-conversation-ref.ts +398 -0
  8. package/agent/channels/teams-directory.ts +193 -0
  9. package/agent/channels/teams-engagement.ts +391 -0
  10. package/agent/channels/teams-fire-eligibility.ts +152 -0
  11. package/agent/channels/teams-front.ts +1404 -0
  12. package/agent/channels/teams-graph-boundary.ts +178 -0
  13. package/agent/channels/teams-graph-team-id.ts +106 -0
  14. package/agent/channels/teams-id-restore.ts +131 -0
  15. package/agent/channels/teams-identity.ts +395 -0
  16. package/agent/channels/teams-input-response.ts +129 -0
  17. package/agent/channels/teams-message-ops.ts +79 -0
  18. package/agent/channels/teams-origin-codec.ts +104 -0
  19. package/agent/channels/teams-post-arm.ts +323 -0
  20. package/agent/channels/teams-reaction-feedback.ts +127 -0
  21. package/agent/channels/teams-read.ts +510 -0
  22. package/agent/channels/teams-roster.ts +136 -0
  23. package/agent/channels/teams-stop.ts +319 -0
  24. package/agent/channels/teams-supervisor.ts +638 -0
  25. package/agent/channels/teams.ts +270 -0
  26. package/agent/contributions.ts +31 -0
  27. package/agent/docs-generated.ts +15 -0
  28. package/agent/ops.ts +129 -0
  29. package/agent/provider-events.ts +69 -0
  30. package/agent/register.ts +219 -0
  31. package/agent/surface-parity.ts +112 -0
  32. package/dist/agent/channels/teams-bot-name.d.ts +36 -0
  33. package/dist/agent/channels/teams-bot-name.d.ts.map +1 -0
  34. package/dist/agent/channels/teams-bot-name.js +141 -0
  35. package/dist/agent/channels/teams-bot-name.js.map +1 -0
  36. package/dist/agent/channels/teams-catch-up.d.ts +53 -0
  37. package/dist/agent/channels/teams-catch-up.d.ts.map +1 -0
  38. package/dist/agent/channels/teams-catch-up.js +221 -0
  39. package/dist/agent/channels/teams-catch-up.js.map +1 -0
  40. package/dist/agent/channels/teams-connector.d.ts +57 -0
  41. package/dist/agent/channels/teams-connector.d.ts.map +1 -0
  42. package/dist/agent/channels/teams-connector.js +132 -0
  43. package/dist/agent/channels/teams-connector.js.map +1 -0
  44. package/dist/agent/channels/teams-conversation-ref.d.ts +123 -0
  45. package/dist/agent/channels/teams-conversation-ref.d.ts.map +1 -0
  46. package/dist/agent/channels/teams-conversation-ref.js +368 -0
  47. package/dist/agent/channels/teams-conversation-ref.js.map +1 -0
  48. package/dist/agent/channels/teams-directory.d.ts +11 -0
  49. package/dist/agent/channels/teams-directory.d.ts.map +1 -0
  50. package/dist/agent/channels/teams-directory.js +179 -0
  51. package/dist/agent/channels/teams-directory.js.map +1 -0
  52. package/dist/agent/channels/teams-engagement.d.ts +174 -0
  53. package/dist/agent/channels/teams-engagement.d.ts.map +1 -0
  54. package/dist/agent/channels/teams-engagement.js +336 -0
  55. package/dist/agent/channels/teams-engagement.js.map +1 -0
  56. package/dist/agent/channels/teams-fire-eligibility.d.ts +26 -0
  57. package/dist/agent/channels/teams-fire-eligibility.d.ts.map +1 -0
  58. package/dist/agent/channels/teams-fire-eligibility.js +126 -0
  59. package/dist/agent/channels/teams-fire-eligibility.js.map +1 -0
  60. package/dist/agent/channels/teams-front.d.ts +249 -0
  61. package/dist/agent/channels/teams-front.d.ts.map +1 -0
  62. package/dist/agent/channels/teams-front.js +1143 -0
  63. package/dist/agent/channels/teams-front.js.map +1 -0
  64. package/dist/agent/channels/teams-graph-boundary.d.ts +45 -0
  65. package/dist/agent/channels/teams-graph-boundary.d.ts.map +1 -0
  66. package/dist/agent/channels/teams-graph-boundary.js +168 -0
  67. package/dist/agent/channels/teams-graph-boundary.js.map +1 -0
  68. package/dist/agent/channels/teams-graph-team-id.d.ts +30 -0
  69. package/dist/agent/channels/teams-graph-team-id.d.ts.map +1 -0
  70. package/dist/agent/channels/teams-graph-team-id.js +89 -0
  71. package/dist/agent/channels/teams-graph-team-id.js.map +1 -0
  72. package/dist/agent/channels/teams-id-restore.d.ts +34 -0
  73. package/dist/agent/channels/teams-id-restore.d.ts.map +1 -0
  74. package/dist/agent/channels/teams-id-restore.js +123 -0
  75. package/dist/agent/channels/teams-id-restore.js.map +1 -0
  76. package/dist/agent/channels/teams-identity.d.ts +148 -0
  77. package/dist/agent/channels/teams-identity.d.ts.map +1 -0
  78. package/dist/agent/channels/teams-identity.js +336 -0
  79. package/dist/agent/channels/teams-identity.js.map +1 -0
  80. package/dist/agent/channels/teams-input-response.d.ts +23 -0
  81. package/dist/agent/channels/teams-input-response.d.ts.map +1 -0
  82. package/dist/agent/channels/teams-input-response.js +102 -0
  83. package/dist/agent/channels/teams-input-response.js.map +1 -0
  84. package/dist/agent/channels/teams-message-ops.d.ts +3 -0
  85. package/dist/agent/channels/teams-message-ops.d.ts.map +1 -0
  86. package/dist/agent/channels/teams-message-ops.js +72 -0
  87. package/dist/agent/channels/teams-message-ops.js.map +1 -0
  88. package/dist/agent/channels/teams-origin-codec.d.ts +16 -0
  89. package/dist/agent/channels/teams-origin-codec.d.ts.map +1 -0
  90. package/dist/agent/channels/teams-origin-codec.js +99 -0
  91. package/dist/agent/channels/teams-origin-codec.js.map +1 -0
  92. package/dist/agent/channels/teams-post-arm.d.ts +37 -0
  93. package/dist/agent/channels/teams-post-arm.d.ts.map +1 -0
  94. package/dist/agent/channels/teams-post-arm.js +280 -0
  95. package/dist/agent/channels/teams-post-arm.js.map +1 -0
  96. package/dist/agent/channels/teams-reaction-feedback.d.ts +22 -0
  97. package/dist/agent/channels/teams-reaction-feedback.d.ts.map +1 -0
  98. package/dist/agent/channels/teams-reaction-feedback.js +108 -0
  99. package/dist/agent/channels/teams-reaction-feedback.js.map +1 -0
  100. package/dist/agent/channels/teams-read.d.ts +127 -0
  101. package/dist/agent/channels/teams-read.d.ts.map +1 -0
  102. package/dist/agent/channels/teams-read.js +424 -0
  103. package/dist/agent/channels/teams-read.js.map +1 -0
  104. package/dist/agent/channels/teams-roster.d.ts +54 -0
  105. package/dist/agent/channels/teams-roster.d.ts.map +1 -0
  106. package/dist/agent/channels/teams-roster.js +121 -0
  107. package/dist/agent/channels/teams-roster.js.map +1 -0
  108. package/dist/agent/channels/teams-stop.d.ts +74 -0
  109. package/dist/agent/channels/teams-stop.d.ts.map +1 -0
  110. package/dist/agent/channels/teams-stop.js +252 -0
  111. package/dist/agent/channels/teams-stop.js.map +1 -0
  112. package/dist/agent/channels/teams-supervisor.d.ts +351 -0
  113. package/dist/agent/channels/teams-supervisor.d.ts.map +1 -0
  114. package/dist/agent/channels/teams-supervisor.js +366 -0
  115. package/dist/agent/channels/teams-supervisor.js.map +1 -0
  116. package/dist/agent/channels/teams.d.ts +5 -0
  117. package/dist/agent/channels/teams.d.ts.map +1 -0
  118. package/dist/agent/channels/teams.js +246 -0
  119. package/dist/agent/channels/teams.js.map +1 -0
  120. package/dist/agent/contributions.d.ts +12 -0
  121. package/dist/agent/contributions.d.ts.map +1 -0
  122. package/dist/agent/contributions.js +31 -0
  123. package/dist/agent/contributions.js.map +1 -0
  124. package/dist/agent/docs-generated.d.ts +3 -0
  125. package/dist/agent/docs-generated.d.ts.map +1 -0
  126. package/dist/agent/docs-generated.js +13 -0
  127. package/dist/agent/docs-generated.js.map +1 -0
  128. package/dist/agent/ops.d.ts +14 -0
  129. package/dist/agent/ops.d.ts.map +1 -0
  130. package/dist/agent/ops.js +122 -0
  131. package/dist/agent/ops.js.map +1 -0
  132. package/dist/agent/provider-events.d.ts +3 -0
  133. package/dist/agent/provider-events.d.ts.map +1 -0
  134. package/dist/agent/provider-events.js +67 -0
  135. package/dist/agent/provider-events.js.map +1 -0
  136. package/dist/agent/register.d.ts +16 -0
  137. package/dist/agent/register.d.ts.map +1 -0
  138. package/dist/agent/register.js +198 -0
  139. package/dist/agent/register.js.map +1 -0
  140. package/dist/agent/surface-parity.d.ts +3 -0
  141. package/dist/agent/surface-parity.d.ts.map +1 -0
  142. package/dist/agent/surface-parity.js +106 -0
  143. package/dist/agent/surface-parity.js.map +1 -0
  144. package/package.json +183 -4
  145. package/tsconfig.build.json +22 -0
  146. package/tsconfig.json +15 -0
@@ -0,0 +1,1404 @@
1
+ // The Teams FRONT route — the single-window legs' live wiring, porting slack-front.ts's proven
2
+ // shape to Teams (build order: single-window-generalisation-strategy.md task #46, Teams arm).
3
+ //
4
+ // WHY A FRONT (same reason as Slack): eve's `teamsChannel` owns the public Bot-Framework webhook
5
+ // and dispatches every message activity to its own conversation-session machinery; it exposes no
6
+ // hook that can DIVERT a wake into another channel (`onMessage` has no cross-channel `to`).
7
+ // So the public URL is fronted: the wrapped `teamsChannel` is mounted at an INTERNAL route
8
+ // ({@link INTERNAL_TEAMS_ROUTE}) and OUR route sits at the public {@link PUBLIC_TEAMS_ROUTE}.
9
+ // Both routes live on the SAME channel object (one file slug → one channel name → one session
10
+ // namespace), exactly the slack-front composition.
11
+ //
12
+ // The front route:
13
+ // 1. Verifies the inbound request (eve's PUBLIC `verifyTeamsRequest` — the Bot Connector JWT
14
+ // against Microsoft's OpenID keys + our app id; fail-closed 401). Unlike the Slack front we
15
+ // don't re-implement the check: eve exports it. The forward path re-verifies inside
16
+ // teamsChannel (a fresh Request), so the native path's auth is unchanged — we only ADD a
17
+ // gate, never weaken one.
18
+ // 2. Parses the activity (eve's public `parseTeamsActivity`). Anything that isn't a message
19
+ // activity — invokes (HITL card clicks), conversationUpdates, unparseable bodies — FORWARDS
20
+ // to teamsChannel untouched (the fail-safe default).
21
+ // 3. For a message activity, resolves the SINGLE-WINDOW plan (teams-supervisor.ts
22
+ // planTeamsSingleWindow: grain singleWindow + the fail-closed Teams privacy floor). NO plan
23
+ // → FORWARD (byte-identical: with the window gates off, every activity takes today's
24
+ // native path). A plan → the activity ITSELF is diverted into the unified window —
25
+ // SINGLE-LEG: Teams fires ONE webhook per message and the floor needs no network, so one
26
+ // synchronous verdict drives both the native suppression (we don't forward) and the
27
+ // addressed/ambient wake (we dispatch) — the Slack build's lost-mention window (two legs
28
+ // disagreeing across webhooks/caches) cannot exist here by construction.
29
+ // 4. Idempotency (two-layer, mirroring slack-front: in-memory + the durable operational store)
30
+ // and per-container FIFO guard the wake.
31
+ //
32
+ // LEG A here is the Teams AMBIENT ingest: a non-mention channel message on a single-window
33
+ // domain becomes a judged channel-watch wake in the shared window. NOTE the platform reality:
34
+ // Teams only delivers non-mention messages to a bot whose app carries the matching RSC permission
35
+ // — `ChannelMessage.Read.Group` for a team channel, `ChatMessage.Read.Chat` for a chat (both are
36
+ // in teams-app/manifest.json). Without the relevant one, only @mention activities arrive and Leg A
37
+ // is simply never exercised (no code path depends on it firing).
38
+ //
39
+ // CHAT SCOPE IS DELIBERATELY NOT LEG A. The plan's privacy floor (teams-supervisor.ts
40
+ // teamsSingleWindowPrivacyOk) admits `scope === "channel"` only, so a group chat never joins the
41
+ // shared cross-surface window. That is the intended posture, not an oversight: a group chat is a
42
+ // per-person surface, not an org container, so admitting one would breach the floor's whole premise
43
+ // (see the floor's docstring). Widening it is a security decision that needs its own review, not a
44
+ // side effect of a manifest line.
45
+ //
46
+ // LEG E is the AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) and it covers BOTH containers a
47
+ // Teams engagement can be kept per: a channel's reply chain and a group chat. An @mention marks the
48
+ // conversation engaged, a later plain message in it wakes the agent, and `stand_down` stores the
49
+ // stop. Leg E dispatches nothing itself — it marks, and it suppresses a stood-down conversation
50
+ // above every other branch — so no message is ever handled by both legs. The two legs answer
51
+ // different questions: Leg A is a DOMAIN's opted-in ambient posture (a vouched single-window
52
+ // channel), Leg E is a CONVERSATION's own invitation, and it needs no domain opt-in at all.
53
+ //
54
+ // Both legs depend on the SAME platform grant, and Leg E states it out loud: Microsoft delivers
55
+ // non-mention activities only where the tenant consented to `ChannelMessage.Read.Group` (a channel)
56
+ // or `ChatMessage.Read.Chat` (a chat). Nothing can probe that consent, so teams-engagement.ts proves
57
+ // the grant present on the first non-mention message and logs the named reason once until then.
58
+
59
+ import { POST, type Channel, type HttpRouteDefinition, type RouteHandlerArgs } from "eve/channels";
60
+ import { verifyTeamsRequest, parseTeamsActivity, teamsThreadRootActivityId, isTeamsInputResponseActivity, type TeamsMessageActivity } from "eve/channels/teams";
61
+ import { sendTeamsActivityBackedOff } from "./teams-connector.js";
62
+ import {
63
+ buildTeamsAuth,
64
+ teamsMembershipType,
65
+ teamsTrustGroup,
66
+ teamsUserTypeFromMember,
67
+ withTeamsUserType,
68
+ type OpenEverywhereTeamsAuth,
69
+ } from "./teams-identity.js";
70
+ import { resolveTeamsUserType, teamsServiceUrlRosterProbe, type TeamsRosterProbe } from "./teams-roster.js";
71
+ import { recordTeamsConversationReference, teamsChannelGrainConversationId, teamsReplyChainRoot } from "./teams-conversation-ref.js";
72
+ import { parseTeamsReaction, routeTeamsStopReaction, type TeamsReactionActivity, type TeamsStopOutcome } from "./teams-stop.js";
73
+ import {
74
+ clearTeamsRoomStop,
75
+ markTeamsEngaged,
76
+ markTeamsRoomWatched,
77
+ resolveTeamsEngagement,
78
+ teamsMachineAuthored,
79
+ teamsRoomWatchStopped,
80
+ type TeamsEngagementVerdict,
81
+ } from "./teams-engagement.js";
82
+ import { teamsGraphTeamId } from "./teams-graph-team-id.js";
83
+ import {
84
+ planTeamsSingleWindow,
85
+ handleTeamsSingleWindowDispatch,
86
+ teamsSupervisorToken,
87
+ buildTeamsDmWake,
88
+ type TeamsSupervisorDeps,
89
+ type TeamsSingleWindowFacts,
90
+ } from "./teams-supervisor.js";
91
+ import {
92
+ shouldRotate,
93
+ EventDedupe,
94
+ ThreadFifo,
95
+ divertToUnifiedWindow,
96
+ divertInboundToUnifiedWindow,
97
+ supervisorGrainFor,
98
+ mintRotationToken,
99
+ claimRuntimeRotation,
100
+ stampMintedRuntime,
101
+ grainUsesUnifiedWindow,
102
+ resolvePartition,
103
+ domainKeyOf,
104
+ engagementTierFor,
105
+ getConfig,
106
+ getOperationalStore,
107
+ isVouchedSurface,
108
+ trustGroupKeyFor,
109
+ resolveWakeTrustFromContext,
110
+ stampNonMemberChatAuth,
111
+ deploymentOrg,
112
+ appendAudit,
113
+ getOrCreateDmSeat,
114
+ touchDmSeat,
115
+ renderWake,
116
+ mintWakeId,
117
+ resolveExternalPolicy,
118
+ externalPolicyOverrideFor,
119
+ externalPolicyIsInert,
120
+ withholdsBody,
121
+ roomIsSilenced,
122
+ silentRoomNotice,
123
+ silentNoticeKey,
124
+ EXTERNAL_SENDER_NOTICE,
125
+ EXTERNAL_MENTION_ACK,
126
+ owesSuppressedMentionAck,
127
+ suppressionRendering,
128
+ type ExternalPolicy,
129
+ type IngestedChatMessage,
130
+ type AuthorTrustQuery,
131
+ type SupervisorGrain,
132
+ type UnifiedWindowWake,
133
+ type UnifiedWakeOrigin,
134
+ type Partition,
135
+ type BindableContext,
136
+ carryTailForward,
137
+ type WakeMessageInput,
138
+ recordWakeTail,
139
+ tailAuthorGrain,
140
+ dmWindowRegistryKey,
141
+ type AttachSessionForCancel,
142
+ RateLimitExhaustedError,
143
+ receiptEventTime,
144
+ spreadVendorTs,
145
+ seatCatchUp,
146
+ } from "@open-everywhere/core/agent/adapter-api.js";
147
+
148
+ /** An HTTP route handler — `RouteHandler` isn't a public eve export, so we mirror its shape
149
+ * (the exact slack-front mirror). */
150
+ type RouteHandler = (req: Request, args: RouteHandlerArgs) => Promise<Response>;
151
+
152
+ /** The public URL the Bot Framework POSTs every activity to (the bot registration's endpoint). */
153
+ export const PUBLIC_TEAMS_ROUTE = "/eve/v1/teams";
154
+ /** The internal path the wrapped `teamsChannel` is mounted at (never hit by Teams directly). */
155
+ export const INTERNAL_TEAMS_ROUTE = "/eve/v1/teams/native";
156
+
157
+ function tlog(msg: string): void {
158
+ console.log(`[open-everywhere:teams-front] ${msg}`);
159
+ }
160
+
161
+ // --- the single-window plan for one verified activity (live resolution) ----------------------
162
+
163
+ /** Everything the front resolved for one activity that the window OWNS — null means "forward to
164
+ * the native channel" (the byte-identical default). */
165
+ export interface TeamsFrontPlan {
166
+ readonly window: UnifiedWindowWake;
167
+ /** The DOMAIN-GRAIN surface key (`teams/<tenant>/<channel>` after the binding's segment
168
+ * sanitisation) — the gate/registry/FIFO/audit key. */
169
+ readonly surfaceKey: string;
170
+ readonly partition: Partition;
171
+ readonly grain: SupervisorGrain;
172
+ readonly auth: OpenEverywhereTeamsAuth;
173
+ }
174
+
175
+ /** One auth bag's raw attributes, defensively — the bags this file handles arrive in two shapes
176
+ * (`OpenEverywhereTeamsAuth` and the erased attribute-bag the DM divert carries). */
177
+ function teamsAttrs(auth: unknown): Record<string, unknown> {
178
+ const a = (auth as { attributes?: unknown } | null)?.attributes;
179
+ return a && typeof a === "object" ? (a as Record<string, unknown>) : {};
180
+ }
181
+
182
+ /**
183
+ * Did Teams POSITIVELY refute this author's membership (OPE-489)? Only a resolved `userRole: "guest"`
184
+ * counts. Absent is NOT a refutation: absence of evidence, and this leg fails OPEN.
185
+ *
186
+ * The auth bag carries the verdict once {@link teamsAuthWithUserType} has folded the roster probe's
187
+ * answer in (OPE-493). Both dispatch paths do that unconditionally; the pre-ack
188
+ * {@link liveTeamsExternalGate} does it only when the policy could act on the answer.
189
+ *
190
+ * The verdict routes through `teamsUserTypeFromMember`, the ONE reader of Teams' guest vocabulary,
191
+ * rather than re-spelling the string comparison here. A second reader is how "what counts as a
192
+ * guest" comes to mean two things — and the vocabulary is already subtle (Graph's
193
+ * `conversationMember.roles` gives an OUT-OF-TENANT external the value `owner`).
194
+ */
195
+ function teamsAuthorRefuted(auth: unknown): boolean {
196
+ const a = teamsAttrs(auth);
197
+ return teamsUserTypeFromMember({ userType: a["user_type"] ?? a["userType"] }) === "guest";
198
+ }
199
+
200
+ /** The trust registry's GROUP-rung key for this activity's TEAM (OPE-490), so a `team:<id>`
201
+ * declaration reaches the room leg exactly as it reaches the binding. Absent on a chat with no
202
+ * team, and on a team id that is not a safe key segment (refused, never repaired). */
203
+ function teamsRoomGroupKey(auth: unknown): { readonly groupKey?: string } {
204
+ const a = teamsAttrs(auth);
205
+ const tenant = a["tenant_id"] ?? a["tenantId"];
206
+ const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
207
+ if (typeof tenant !== "string" || tenant.length === 0 || !group) return {};
208
+ return { groupKey: `teams/${tenant}/${group.trustGroup}` };
209
+ }
210
+
211
+ /**
212
+ * The auth capture for one inbound activity — the SAME raw-fact stow teams.ts's onMessage builds,
213
+ * so the diverted wake binds identically to a native turn (identity seam unchanged).
214
+ *
215
+ * SYNCHRONOUS, so it carries no `userType`: the roster verdict is a network call. The two dispatch
216
+ * paths fold it in afterwards with {@link teamsAuthWithUserType}.
217
+ */
218
+ function authForActivity(a: TeamsMessageActivity): OpenEverywhereTeamsAuth {
219
+ return buildTeamsAuth({
220
+ userId: a.from.id,
221
+ aadObjectId: a.from.aadObjectId,
222
+ userName: a.from.name,
223
+ tenantId: a.tenantId ?? a.conversation.tenantId,
224
+ conversationId: a.conversation.id,
225
+ channelId: a.teamsChannelId,
226
+ teamId: a.teamId,
227
+ serviceUrl: a.serviceUrl,
228
+ scope: a.scope,
229
+ membershipType: teamsMembershipType(a.channelData),
230
+ });
231
+ }
232
+
233
+ /**
234
+ * FOLD THE ROSTER VERDICT INTO ONE ACTIVITY'S AUTH CAPTURE (OPE-493) — the ONE step both front
235
+ * dispatch paths take before they stamp, and the reason the front's author leg is no longer inert.
236
+ *
237
+ * Until this existed, `resolveTeamsUserType` ran on NO live path. The front owns channel activities
238
+ * (the shared window) and personal/group chats (the OPE-357 DM divert), and its capture was built
239
+ * without a `userType` — so a measured B2B guest woke the window rendered as a `peer`
240
+ * (session wrun_41KZXE4FGK0GPSJB4JNM4KCMHY, 2026-08-15). The verdict now rides the SAME attribute
241
+ * the native path uses, so `bindTeams` → `teamsTrust` demotes the guest to `external` and the
242
+ * OPE-489 author leg answers, with no change of their own.
243
+ *
244
+ * THREE CALLERS, TWO LATENCY BUDGETS. The two DISPATCH paths run inside the front's
245
+ * `waitUntil(fifo.enqueue(…))`, so their roster GET lands AFTER the 200 ack and costs the Bot
246
+ * Framework nothing — they call this unconditionally. {@link liveTeamsExternalGate} runs BEFORE the
247
+ * ack, so it calls this only when a grain could act on the answer; its own note carries the reasoning.
248
+ *
249
+ * COST: one cached GET per (CHANNEL, user) per `identity.dmTrustTtlMs`, never one per message.
250
+ * `conversation.id` on a CHANNEL activity is the REPLY-CHAIN id (it carries `;messageid=<root>`), and
251
+ * `resolveTeamsUserType` strips that suffix for the CACHE KEY, so every reply chain in one channel
252
+ * shares one entry (OPE-572). The id still reaches the CONNECTOR unstripped — exactly as the native
253
+ * `teamsOnMessage` passes it, which is the shape the 2026-08-15 live probe measured. Whether the
254
+ * connector ALSO accepts a thread-qualified id is NOT established; one live GET settles it, and a
255
+ * refusal fails open like any other non-2xx.
256
+ *
257
+ * FAIL-OPEN, TOTALLY: a missing serviceUrl, an HTTP error, a transport throw, an unrecognised body —
258
+ * every one of them returns the capture UNCHANGED, by reference (see `withTeamsUserType`). The
259
+ * try/catch covers the probe FACTORY as well as the probe, so no roster failure can take a wake off
260
+ * the air.
261
+ *
262
+ * `probeFor` is the test seam: a FACTORY, not a probe, so a test can script "this activity has no
263
+ * probe at all" (`() => undefined`) distinctly from "the probe answers nothing". Live callers omit
264
+ * it and get the activity's own serviceUrl probe.
265
+ */
266
+ export type TeamsRosterProbeFor = (activity: TeamsMessageActivity) => TeamsRosterProbe | undefined;
267
+
268
+ async function teamsAuthWithUserType<A extends OpenEverywhereTeamsAuth>(
269
+ auth: A,
270
+ a: TeamsMessageActivity,
271
+ probeFor?: TeamsRosterProbeFor,
272
+ ): Promise<A> {
273
+ const factory: TeamsRosterProbeFor = probeFor ?? ((activity) => teamsServiceUrlRosterProbe(activity.serviceUrl));
274
+ try {
275
+ return withTeamsUserType(auth, await resolveTeamsUserType(factory(a), a.conversation.id, a.from.id));
276
+ } catch {
277
+ return auth; // no verdict → byte-identical capture
278
+ }
279
+ }
280
+
281
+ // --- THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ---------------
282
+ //
283
+ // Teams is the strongest adopter after Discord and Notion, and it is the one surface that can answer
284
+ // BOTH grains from vendor evidence: the Bot Connector roster reports `userRole: "guest"` for the
285
+ // author leg (teams-roster.ts), and a Teams
286
+ // SHARED CHANNEL is a positive vendor-external verdict for the room leg. The decision plane is the
287
+ // shared, surface-agnostic access/external-policy.ts — this file contributes only the Teams facts and
288
+ // the Teams consequences.
289
+ //
290
+ // 🔴 THE GATE SITS IN THE FRONT, ABOVE BOTH BRANCHES, WHICH IS WHY IT COVERS THE NATIVE PATH TOO.
291
+ // A suppressed activity is never forwarded to the wrapped `teamsChannel`, so a deployment with the
292
+ // window gates OFF is protected by the same verdict as one with them on. Putting the gate inside
293
+ // `liveTeamsDispatch` would have left every non-single-window Teams deployment unwired.
294
+
295
+ /**
296
+ * The resolved external-participant policy for one room: the ORG DEFAULT, with the DOMAIN OVERRIDE
297
+ * applied when the domain registry can be read. An unreadable registry degrades to the org default —
298
+ * today's behaviour — rather than throwing, exactly as discord-gateway.ts's `externalPolicyForRoom`
299
+ * does and for the same reason: the policy plane must never be the thing that takes a surface off
300
+ * the air.
301
+ */
302
+ function teamsExternalPolicyForRoom(roomKey: string): ExternalPolicy {
303
+ try {
304
+ return resolveExternalPolicy(externalPolicyOverrideFor(roomKey, getConfig().loadDomains()));
305
+ } catch {
306
+ return resolveExternalPolicy();
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The AUTHOR-TRUST question for one activity (OPE-516) — the OPERATOR's own declaration of who is
312
+ * internal in this tenant.
313
+ *
314
+ * 🔴 TEAMS NEEDS IT EVEN THOUGH TEAMS CAN REFUTE. The surface refutation (`teamsAuthorRefuted`) reads
315
+ * a resolved roster `userRole`, which every front path now resolves (OPE-493). It still does not
316
+ * answer everywhere: a tenant whose bot credential cannot read the roster gets no verdict, and the
317
+ * probe only ever REFUTES — it never declares anyone internal. The register is the leg that covers
318
+ * those. It is additive: an unarmed tenant declares nobody and the leg contributes nothing, so a
319
+ * deployment that has not armed a register sees no behaviour change at all.
320
+ *
321
+ * Returns `{}` when the tenant is unreadable — no tenant key, no arming key, no question to ask.
322
+ */
323
+ export function teamsAuthorFacts(auth: unknown, authorId: string | undefined): { readonly authorTrust?: AuthorTrustQuery } {
324
+ const a = teamsAttrs(auth);
325
+ const tenant = a["tenant_id"] ?? a["tenantId"];
326
+ if (typeof tenant !== "string" || tenant.length === 0) return {};
327
+ const group = teamsTrustGroup(typeof (a["team_id"] ?? a["teamId"]) === "string" ? String(a["team_id"] ?? a["teamId"]) : undefined);
328
+ return {
329
+ authorTrust: {
330
+ tenantKey: `teams/${tenant}`,
331
+ ...(authorId ? { authorId } : {}),
332
+ // The GROUP-rung key this author holds, in the register's own pattern grammar. Teams gives one
333
+ // rung (the team), not a role list — a member of the team holds `teams/<tenant>/team:<id>`.
334
+ ...(group ? { memberships: [`teams/${tenant}/${group.trustGroup}`] } : {}),
335
+ },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * The SURFACE's own vendor-external verdict for one activity: this room spans another organisation.
341
+ *
342
+ * A Teams SHARED CHANNEL (`channelData.channel.membershipType === "shared"`) is exactly that — it can
343
+ * hold members from tenants that are not ours, which is why teams-identity.ts already demotes one to
344
+ * `external` for the memory tier. Absent or any other value means "not reported", which includes "the
345
+ * activity did not carry channelData": absence of evidence, and this leg fails OPEN.
346
+ */
347
+ export function teamsVendorExternal(a: TeamsMessageActivity): boolean {
348
+ return teamsMembershipType(a.channelData) === "shared";
349
+ }
350
+
351
+ /**
352
+ * THE EXTERNAL-PARTICIPANT VERDICT for one Teams activity — pure, so the four outcomes are tested
353
+ * without a Bot-Framework call, a store or a live tenant. The four values name WHAT THE CALLER OWES;
354
+ * see {@link DiscordExternalVerdict}'s twin in discord-gateway.ts for the full reasoning.
355
+ *
356
+ * 🔴 THE AUTHOR GRAIN IS TESTED FIRST, AND THE ORDER MATTERS — the same ruling Discord's verdict
357
+ * carries. An excluded author in a silenced room is owed a notice they can act on; the room
358
+ * announcement tells the whole room something about the room instead.
359
+ */
360
+ export type TeamsExternalVerdict = "proceed" | "withheld-addressed" | "withheld-ambient" | "silent-room";
361
+
362
+ export function teamsExternalVerdict(input: {
363
+ readonly policy: ExternalPolicy;
364
+ readonly author: IngestedChatMessage;
365
+ /** The activity @mentions the bot, or arrived in a personal chat — an ADDRESSED message is owed
366
+ * an answer. */
367
+ readonly addressed: boolean;
368
+ /** A personal or group chat. A DM contributes NO room leg: on a DM the trust plane asks about the
369
+ * PARTNER, not about the room. */
370
+ readonly isDm: boolean;
371
+ /** The SURFACE's vendor-external verdict — {@link teamsVendorExternal}. */
372
+ readonly vendorExternal: boolean;
373
+ /** The DOMAIN-grain surface key of the room, for the operator-declared leg. */
374
+ readonly roomKey: string;
375
+ /** The GROUP-rung key of the room's team, when the activity names one. */
376
+ readonly groupKey?: string;
377
+ }): TeamsExternalVerdict {
378
+ if (withholdsBody(input.author, input.policy)) return input.addressed ? "withheld-addressed" : "withheld-ambient";
379
+ const room = input.isDm ? { dm: true } : { surfaceKey: input.roomKey, ...(input.groupKey === undefined ? {} : { groupKey: input.groupKey }) };
380
+ return roomIsSilenced(
381
+ { room, ...(input.vendorExternal ? { vendorExternal: true } : {}), ingested: [input.author] },
382
+ input.policy,
383
+ )
384
+ ? "silent-room"
385
+ : "proceed";
386
+ }
387
+
388
+ /**
389
+ * Best-effort Bot-Framework send into the activity's own conversation. Never throws: a notice is
390
+ * an affordance, and a failed one must not turn a suppressed message into a 500 the Bot Framework
391
+ * retries. Answers whether the send was accepted, which the OPE-885 ack fallback reads. The
392
+ * serviceUrl is re-validated by the transport arm's own host allowlist.
393
+ *
394
+ * 🔴 A TIGHTENED RETRY POLICY, NOT THE SHARED DEFAULTS (cold review finding, OPE-1237). This
395
+ * function is reached from `liveTeamsExternalGate`, which `dispatchMessage` (:954) AWAITS before
396
+ * returning `OK()` on the route Microsoft posts activities to — the same "latency-critical path"
397
+ * shape `slack-web-api.ts`'s `PROBE_OPERATIONS` exists for. The Bot Framework redelivers on a slow
398
+ * ack (this file's own dedupe comment states redelivery is FAIL-OPEN), so the shared 60 s budget
399
+ * would turn one Teams rate-limit episode into a redelivery storm instead of a lost notice. Two
400
+ * attempts at a 2 s cap, mirroring `auth.test`'s policy exactly.
401
+ */
402
+ async function postTeamsNotice(a: TeamsMessageActivity, text: string): Promise<boolean> {
403
+ try {
404
+ await sendTeamsActivityBackedOff(
405
+ { serviceUrl: a.serviceUrl, conversationId: a.conversation.id, body: { type: "message", text } },
406
+ { retry: { maxAttempts: 2, capMs: 2_000 } },
407
+ );
408
+ return true;
409
+ } catch (err) {
410
+ // 🔴 LOG A RATE-LIMIT EXHAUSTION BEFORE SWALLOWING IT (OPE-1237), mirroring Slack's
411
+ // slack-events.ts intro fetch. This catch is fail-soft by design, and without this line an
412
+ // exhausted 429 retry would leave NO trace anywhere — the silent drop the ticket exists to
413
+ // prevent. Every field is token-free.
414
+ if (err instanceof RateLimitExhaustedError) {
415
+ console.warn(`[open-everywhere:teams] rate limited: conversations.activities.send gave up after ${err.attempts} attempts (waited ${err.waitedMs} ms)`);
416
+ }
417
+ return false; // silence — see the caller's own no-fallback reasoning
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Tell an EXCLUDED sender that the agent will not act on their message (behaviour 1). Answers
423
+ * whether the notice reached them.
424
+ *
425
+ * 🔴 THIS NOTICE NEVER GOES TO A TEAM CHANNEL, AND THAT RULING STANDS — the same one Discord's
426
+ * `postExternalSenderNotice` carries. Its text names the organisation, so posting it into the
427
+ * channel would announce that this person is classified as an outsider: a disclosure about a member
428
+ * of the room, not a notice to the sender. Teams cannot open a 1:1 with a person the bot has never
429
+ * met (the Bot Framework needs a conversation reference captured on a prior inbound turn), so there
430
+ * is no private channel to fall back to. A PERSONAL chat is itself private, so the notice posts
431
+ * there.
432
+ *
433
+ * 🔴 SO A CHANNEL @MENTION ALWAYS ANSWERS `false`, AND THAT IS THE COMMON CASE (OPE-885). The caller
434
+ * then posts the fixed `EXTERNAL_MENTION_ACK` into the channel instead — a different string, which
435
+ * names no person, no organisation and no verdict, and is therefore not the disclosure this notice
436
+ * would have been.
437
+ */
438
+ async function postTeamsExternalSenderNotice(a: TeamsMessageActivity): Promise<boolean> {
439
+ if (a.scope !== "personal") return false;
440
+ return await postTeamsNotice(a, EXTERNAL_SENDER_NOTICE);
441
+ }
442
+
443
+ /**
444
+ * Post the suppressed-wake half of the silent-room announcement pair (OPE-504) — AT MOST ONCE PER
445
+ * ROOM, EVER.
446
+ *
447
+ * The claim is DURABLE, not per-process: `claimNotice` writes a permanent row, where an in-memory
448
+ * marker would re-announce on every cold start — and a cold start happens per serverless invocation,
449
+ * which would be the periodic announcement the design rejects. FAIL-CLOSED on the claim: if the store
450
+ * cannot prove the notice is unsaid, the agent stays quiet.
451
+ */
452
+ async function announceTeamsSilentRoom(a: TeamsMessageActivity, org: string, surfaceKey: string): Promise<boolean> {
453
+ if (!org) return false;
454
+ try {
455
+ const won = await getOperationalStore().claimNotice(org, silentNoticeKey(surfaceKey, "suppressed-wake"), new Date().toISOString());
456
+ if (!won) return false; // already said, once, forever
457
+ } catch {
458
+ return false; // cannot prove it is unsaid ⇒ stay quiet
459
+ }
460
+ return await postTeamsNotice(a, silentRoomNotice("suppressed-wake"));
461
+ }
462
+
463
+ /**
464
+ * The LIVE gate: resolve the verdict for one verified message activity and pay what it owes.
465
+ * `true` means SUPPRESSED — the caller acks and forwards nothing.
466
+ *
467
+ * FAIL-OPEN on an unresolvable identity. If the auth capture or the partition cannot be built there
468
+ * is no room key, no tenant and no classification to act on, so the activity takes today's path and
469
+ * the existing branches apply their own fail-closed rules. Every carriage leg in this plane fails
470
+ * open, and this one has to as well: a config error must not silently take Teams off the air.
471
+ *
472
+ * 🔴 THE ROSTER PROBE IS PAID FOR ONLY WHEN THE POLICY CAN USE IT. This gate runs BEFORE the 200 ack,
473
+ * so a Bot Connector GET here is latency on EVERY activity — including the ones the front only
474
+ * forwards — and a slow ack makes the Bot Framework redeliver. The shipped grains (`taint` / `engage`)
475
+ * can suppress nothing, so `externalPolicyIsInert` skips the probe entirely and an un-opted-in
476
+ * deployment pays exactly what it paid before. An opted-in one pays one cached GET per
477
+ * (conversation, user) per TTL. This is the same trade `slack-front.ts` makes on its native
478
+ * `app_mention` forward, through the same predicate.
479
+ */
480
+ export async function liveTeamsExternalGate(a: TeamsMessageActivity, probeFor?: TeamsRosterProbeFor): Promise<boolean> {
481
+ const isDm = a.scope === "personal" || a.scope === "groupChat";
482
+ let auth: OpenEverywhereTeamsAuth;
483
+ let partition: Partition;
484
+ let roomKey: string;
485
+ try {
486
+ auth = authForActivity(a);
487
+ partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
488
+ // A DM has no parent container to widen to, so it is keyed on its own surface key — the same
489
+ // split liveTeamsDmDivert makes.
490
+ roomKey = isDm ? partition.surfaceKey : domainKeyOf(partition);
491
+ } catch {
492
+ return false;
493
+ }
494
+ const policy = teamsExternalPolicyForRoom(roomKey);
495
+ // OPE-493: resolve the SURFACE's own refutation, but only when a grain could act on it. Folded in
496
+ // BEFORE the trust read below, so an opted-in deployment sees the demoted tier as well.
497
+ if (!externalPolicyIsInert(policy)) auth = await teamsAuthWithUserType(auth, a, probeFor);
498
+ const trust = resolveWakeTrustFromContext(
499
+ { channel: { kind: "teams" }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } },
500
+ { id: a.from.id, isBot: false },
501
+ { principalFallback: true },
502
+ );
503
+ // EXPLICIT ADDRESSING — one fact, read twice: the verdict owes a private notice on it, and the
504
+ // OPE-885 ack keys on it. A personal chat is addressing by construction; `isBotMentioned` is
505
+ // Teams' own mention signal, which a NAME-DROP never sets.
506
+ const addressed = a.isBotMentioned === true || a.scope === "personal";
507
+ const verdict = teamsExternalVerdict({
508
+ policy,
509
+ // The SAME per-message facts the taint author leg reads: the resolved human tier, the SURFACE's
510
+ // roster refutation, and the OPERATOR's register question.
511
+ author: { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) },
512
+ addressed,
513
+ isDm,
514
+ vendorExternal: teamsVendorExternal(a),
515
+ roomKey,
516
+ ...teamsRoomGroupKey(auth),
517
+ });
518
+ if (verdict === "proceed") return false;
519
+ // The private notice and the once-ever room announcement both ANSWER the person who addressed us,
520
+ // and both say more than the ack does — so either suppresses it.
521
+ let alreadyAnswered = false;
522
+ if (verdict === "withheld-addressed") alreadyAnswered = await postTeamsExternalSenderNotice(a).catch(() => false);
523
+ if (verdict === "silent-room") alreadyAnswered = await announceTeamsSilentRoom(a, partition.org, roomKey).catch(() => false);
524
+ // THE MENTION ACK (OPE-885): an explicit @mention or personal-chat message that got no private
525
+ // answer gets the one fixed host line, in the conversation it was said in. An AMBIENT message
526
+ // stays fully dark — `addressed` is false for it, and this is the only gate.
527
+ const acked =
528
+ owesSuppressedMentionAck({ addressed, alreadyAnswered }) && (await postTeamsNotice(a, EXTERNAL_MENTION_ACK).catch(() => false));
529
+ await appendAudit({
530
+ org: partition.org || deploymentOrg(),
531
+ platform: "teams",
532
+ workspace: partition.workspace,
533
+ surface: roomKey,
534
+ principalId: a.from.id,
535
+ action: "teams.external_policy_suppressed",
536
+ detail: { verdict, conversationId: a.conversation.id, ...suppressionRendering(acked) },
537
+ }).catch(() => undefined);
538
+ tlog(`suppressed ${roomKey}: externalPolicy verdict "${verdict}"`);
539
+ return true;
540
+ }
541
+
542
+ /**
543
+ * Resolve the LIVE single-window plan for one verified message activity, fail-closed: any
544
+ * config/partition error, the gates off, the privacy floor failing, or a missing reply target
545
+ * all yield null — the front then FORWARDS to the native channel, byte-identical to today.
546
+ *
547
+ * The GRAIN + VOUCH are keyed on the DOMAIN-GRAIN key (`teams/<tenant>/<channel>` — a Teams
548
+ * conversation id names one REPLY CHAIN, so the listable/vouchable container is the channel;
549
+ * memory/partition.ts's domainKeyOf resolves it via the binding's domainSurface, mirroring the
550
+ * Discord thread→parent-channel precedent). Operators list/vouch `teams/<tenant>` or
551
+ * `teams/<tenant>/<channel-id>` (sanitised segments, same as every stored key).
552
+ */
553
+ export function liveTeamsFrontPlan(a: TeamsMessageActivity): TeamsFrontPlan | null {
554
+ try {
555
+ const auth = authForActivity(a);
556
+ const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
557
+ const surfaceKey = domainKeyOf(partition);
558
+ const grain = supervisorGrainFor(surfaceKey, getConfig().loadDomains());
559
+ if (!grainUsesUnifiedWindow(grain)) return null; // cross-surface OR solo; common path off-window (byte-identical flag-off)
560
+ const tenantId = a.tenantId ?? a.conversation.tenantId;
561
+ // The TEAM rung, derived exactly as identity/binding.ts derives it (see the `vouched` note
562
+ // below). Undefined on a personal or group chat, and on a malformed team id — `teamsTrustGroup`
563
+ // validates the charset and `trustGroupKeyFor` validates the `<marker>:<id>` shape, both
564
+ // fail-closed to no rung.
565
+ const groupKey = trustGroupKeyFor(partition.workspaceKey, teamsTrustGroup(a.teamId)?.trustGroup);
566
+ const facts: TeamsSingleWindowFacts = {
567
+ scope: a.scope,
568
+ membershipType: teamsMembershipType(a.channelData),
569
+ tenantId,
570
+ // The operator's vouch. `isVouchedSurface` WALKS THE DECLARATION RUNGS (OPE-1521), so a
571
+ // tenant-grain `teams/<tenant>` declaration vouches every room under it, not just the channel
572
+ // key — and `agent/identity/surfaces.ts` ships such a tenant line. A tenant declaration can't
573
+ // tell a native internal channel from a Teams CONNECT / shared channel spanning other
574
+ // tenants, so a shared room whose inbound activity OMITS membershipType passes this floor on
575
+ // that line. The grain check above already limited us to rooms the domain config enrolls — the
576
+ // vouch admits a member, it never enrolls one — but that is no second bound: a container entry
577
+ // claim is legal at tenant grain, and one beside a tenant declaration enrolls the whole
578
+ // tenant. That is the ruling: a declaration vouches, only a POSITIVE external signal
579
+ // demotes. Declare the channel rung to narrow the reach. OE_TRUST=internal is still the
580
+ // deploy-wide blanket switch, and a shared channel is separately refused by the
581
+ // membershipType guard when the signal IS present.
582
+ //
583
+ // 🔴 EVERY RUNG THE BINDING READS IS PASSED HERE TOO (OPE-1538), and that is the whole point:
584
+ // this floor used to be handed the CHANNEL key alone, so two tiers were invisible to it and
585
+ // both misses were fail-open. A `public` `teams/<tenant>/team:<id>` team carve-out and a
586
+ // `public` declaration on ONE reply chain each demoted on the binding and memory planes
587
+ // (identity/binding.ts passes the conversation key, the channel key and the team rung) while
588
+ // this floor saw only the tenant entry and admitted the room — at the one gate where admitting
589
+ // means joining a private cross-surface window. The three keys below are the same three, in
590
+ // the same preference order, derived through the same helpers: `partition.surfaceKey` is the
591
+ // reply chain, `surfaceKey` is `domainKeyOf`'s channel, and `trustGroupKeyFor` +
592
+ // `teamsTrustGroup` build the team rung exactly as the mapper does. One spelling per rung,
593
+ // across both planes. The team rung is absent on a personal or group chat, which carry no team
594
+ // id and are refused by the scope check anyway.
595
+ vouched:
596
+ isVouchedSurface(partition.surfaceKey, { containerKey: surfaceKey, ...(groupKey === undefined ? {} : { groupKey }) }) ||
597
+ process.env.OE_TRUST === "internal",
598
+ };
599
+ const window = planTeamsSingleWindow(true, a.isBotMentioned, facts, {
600
+ serviceUrl: a.serviceUrl,
601
+ conversationId: a.conversation.id,
602
+ replyToActivityId: teamsThreadRootActivityId(a) ?? undefined,
603
+ tenantId,
604
+ // THE FRONT-DOOR RECEIPT (OPE-1716) — a Bot-Framework activity carries no parsed timestamp on
605
+ // eve's shape (the same fact the trigger's `sentAt` records below), so the ingress row records
606
+ // our own instant and marks it `receipt`, which the split reporter renders as a LOWER BOUND.
607
+ // 🔴 Taken HERE, at the plan, not at the route's first byte — so on Teams the vendor→ingress
608
+ // segment is a floor near zero and says nothing about the vendor's own hop.
609
+ ...spreadVendorTs(receiptEventTime(Date.now())),
610
+ });
611
+ if (!window) return null;
612
+ return { window, surfaceKey, partition, grain, auth };
613
+ } catch {
614
+ return null; // fail-closed → the native path keeps ownership
615
+ }
616
+ }
617
+
618
+ // --- the live dispatch (session deps + the diversion) -----------------------------------------
619
+
620
+ /** Live supervisor deps over the (org, grain.registryKey) channel_session registry — the exact
621
+ * notion.ts/linear.ts shape. The wake ALWAYS diverts (teams-supervisor requires the window). */
622
+ /**
623
+ * EXPORTED FOR THE INGRESS CONTRACT TEST (OPE-1719), not for another caller. `liveTeamsDispatch` is
624
+ * the only production caller and it is thirty lines below. `scripts/owned-thread-ingress-wiring-test.ts`
625
+ * drives the `wake` member of THIS object — the real Teams ingress divert — because a test that
626
+ * reimplemented the wake dep would stay green after somebody moved this leg back to the raw divert,
627
+ * which is the exact regression the ticket closes.
628
+ */
629
+ export function liveTeamsSupervisorDeps(plan: TeamsFrontPlan, to: RouteHandlerArgs["to"]): TeamsSupervisorDeps {
630
+ const org = plan.partition.org;
631
+ const store = getOperationalStore();
632
+ const grain = plan.grain;
633
+ return {
634
+ async getOrCreateSession(_surfaceKey, channelId, atIso) {
635
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
636
+ // mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
637
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
638
+ const existing = await store.getChannelSession(org, grain.registryKey);
639
+ if (!existing) {
640
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => teamsSupervisorToken(channelId, 1)), atIso);
641
+ // OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
642
+ // wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
643
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey: plan.surfaceKey, domainName: grain.domainName, atIso });
644
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] as readonly WakeMessageInput[] };
645
+ }
646
+ if (shouldRotate(existing, atIso, { runtimeChanged })) {
647
+ const gen = existing.generation + 1;
648
+ // OPE-794: read the OUTGOING generation's recorded tail BEFORE minting its successor. On
649
+ // Teams this is the ONLY continuity there is — the surface exposes no history read.
650
+ const tail = await carryTailForward(org, grain.registryKey, existing.token);
651
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => teamsSupervisorToken(channelId, gen)), atIso);
652
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail };
653
+ }
654
+ // A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (H7c). Its
655
+ // tail is empty — carried and cleared at the rotation that minted this row.
656
+ // `lastWakeAt` rides out on THIS arm alone: it is the catch-up read's lower bound, and a fresh
657
+ // or just-rotated generation has none (rotation resets the column).
658
+ return {
659
+ token: existing.token,
660
+ generation: existing.generation,
661
+ freshGeneration: existing.firstWakeAt == null,
662
+ tail: [] as readonly WakeMessageInput[],
663
+ lastWakeAt: existing.lastWakeAt ?? null,
664
+ };
665
+ },
666
+ touchSession: (_surfaceKey, atIso) => store.touchChannelSession(org, grain.registryKey, atIso),
667
+ // THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. Teams' own fetch
668
+ // is registered at bundle registration and reads over Microsoft Graph, not the Bot Connector this
669
+ // wake arrived on. The admission policy is the DOMAIN's, resolved by the shared road itself from
670
+ // the request's own channel key (OPE-1857). No `trustFor`: this surface has no synchronous
671
+ // roster probe for a channel author at wake time, so the shared core's FAIL-CLOSED per-sender
672
+ // default applies.
673
+ catchUp: (req) => seatCatchUp("teams", req),
674
+ // OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
675
+ // rotation read and the spend counter, so all three target one row.
676
+ recordWake: (entry, token) => recordWakeTail(org, grain.registryKey, token, entry),
677
+ now: () => new Date().toISOString(),
678
+ async wake({ token, auth, message, context, window }) {
679
+ // SINGLE-WINDOW diversion (channels/unified-window.ts): the wake goes through eve's
680
+ // cross-channel `to(...).send(...)` into the synthetic `unified` channel, so its effective token is
681
+ // `unified:<raw>`, shared with the domain's Slack/GitHub/Linear/Notion wakes — ONE context
682
+ // window. The unified channel's receive registers the wakeId→origin map entry (durably,
683
+ // BEFORE the send) and re-checks the gates fail-closed. A failure propagates to the FIFO's
684
+ // loud catch (fail-closed: never delivered without a routable reply target).
685
+ //
686
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's front
687
+ // route has always used. A wake landing in a reply chain a live task worker or thread seat
688
+ // OWNS is dispatched to that owner's token instead of the standing seat's. On Teams the reply
689
+ // chain IS the thread (`replyToActivityId`), so a plain conversation send stays top-level and
690
+ // never consults ownership. Flag-off, and on every unowned chain, this is a byte-identical
691
+ // passthrough to `divertToUnifiedWindow`.
692
+ await divertInboundToUnifiedWindow(to, {
693
+ message,
694
+ ...(context.length > 0 ? { context } : {}),
695
+ token,
696
+ window,
697
+ surfaceKey: plan.surfaceKey,
698
+ auth: auth as unknown as { attributes?: Record<string, unknown> },
699
+ });
700
+ },
701
+ };
702
+ }
703
+
704
+ // --- the composed front handler ----------------------------------------------------------------
705
+
706
+ /** Injectable seams so the front is testable without live JWT verification / a Bot registration. */
707
+ export interface TeamsFrontOptions {
708
+ /** Verify one inbound request and return the raw body; throw to reject (401). Defaults to
709
+ * eve's `verifyTeamsRequest` with env credentials (MICROSOFT_APP_ID / TEAMS_APP_ID) — the
710
+ * same fallback the credential-less teamsChannel uses. */
711
+ readonly verify?: (req: Request) => Promise<string>;
712
+ readonly dedupe?: EventDedupe;
713
+ readonly fifo?: ThreadFifo;
714
+ readonly now?: () => number;
715
+ /** Durable first-sighting check (true = proceed). Defaults to the operational store. */
716
+ readonly markEventSeen?: (org: string, key: string, atIso: string) => Promise<boolean>;
717
+ /** The single-window plan for one verified activity. Defaults to {@link liveTeamsFrontPlan}
718
+ * (grain + fail-closed privacy floor); fail-closed null ⇒ forward, byte-identical. */
719
+ readonly planFor?: (a: TeamsMessageActivity) => TeamsFrontPlan | null;
720
+ /** The dispatch executor (defaults to the real supervisor dispatch over the live deps). */
721
+ readonly dispatch?: (a: TeamsMessageActivity, plan: TeamsFrontPlan, to: RouteHandlerArgs["to"]) => Promise<void>;
722
+ /** The DM window divert (OPE-357) — defaults to {@link liveTeamsDmDivert}. Injected so the front's
723
+ * DM-routing decision (predicate + fail-closed to native) is unit-testable without a live store. */
724
+ readonly dmDivert?: (a: TeamsMessageActivity, auth: OpenEverywhereTeamsAuth, partition: Partition, to: RouteHandlerArgs["to"]) => Promise<void>;
725
+ /** The external-participant gate (OPE-507/OPE-504) — `true` suppresses the activity. Defaults to
726
+ * {@link liveTeamsExternalGate}; injected so the front's ORDERING (above both branches, so the
727
+ * native forward is covered too) is unit-testable without a store or a Bot registration. */
728
+ readonly externalGate?: (a: TeamsMessageActivity) => Promise<boolean>;
729
+ /** The stop-reaction arm (OPE-898) — defaults to {@link routeTeamsStopReaction} over eve's cancel
730
+ * door. Injected so the front's ORDERING (above `parseTeamsActivity`, which never sees a reaction
731
+ * activity) is unit-testable without a live session. */
732
+ readonly stopReaction?: (rx: TeamsReactionActivity, attachSession: AttachSessionForCancel) => Promise<TeamsStopOutcome>;
733
+ /** The ambient engagement seam's READ (OPE-1500) — defaults to {@link resolveTeamsEngagement}.
734
+ * Injected so the front's ORDERING (above both the DM divert and Leg A, so one message is never
735
+ * handled by two legs) is unit-testable without an operational store. */
736
+ readonly engagementSeam?: (a: TeamsMessageActivity, machineAuthored: boolean) => Promise<TeamsEngagementVerdict>;
737
+ /** The ambient engagement seam's WRITE — defaults to {@link markTeamsEngaged}. Separate from the
738
+ * read so a test can assert that exactly one mention marks exactly one row. */
739
+ readonly markEngaged?: (a: TeamsMessageActivity) => Promise<string | null>;
740
+ /** Leg A's ROOM-grain stop READ (OPE-1511) — defaults to {@link teamsRoomWatchStopped}. Injected
741
+ * so the front's ORDERING (below the mention re-invitation, above the dispatch) is unit-testable
742
+ * without an operational store. */
743
+ readonly roomStopped?: (partition: Partition) => Promise<boolean>;
744
+ /** Leg A's ROOM-grain wake row WRITE — defaults to {@link markTeamsRoomWatched}. */
745
+ readonly markRoomWatched?: (partition: Partition, atIso: string) => Promise<void>;
746
+ /** The @mention's ROOM-grain re-invitation — defaults to {@link clearTeamsRoomStop}. Separate
747
+ * from the read so a test can prove a mention is the way back out of a room stop. */
748
+ readonly clearRoomStop?: (partition: Partition, atIso: string, engagedBy: string) => Promise<void>;
749
+ }
750
+
751
+ const OK = () => new Response("ok");
752
+
753
+ // Durable half of the retry dedupe (Bot Framework redelivers on non-200): mirrors slack-front's
754
+ // liveMarkEventSeen — FAIL-OPEN by design (dedupe prevents a double reply, not a security hole).
755
+ const DEDUPE_TTL_MS = 10 * 60_000;
756
+ let warnedDedupeStore = false;
757
+ async function liveMarkEventSeen(org: string, key: string, atIso: string): Promise<boolean> {
758
+ try {
759
+ return await getOperationalStore().markEventSeen(org, key, atIso, DEDUPE_TTL_MS);
760
+ } catch (err) {
761
+ if (!warnedDedupeStore) {
762
+ warnedDedupeStore = true;
763
+ console.warn(`[open-everywhere:teams-front] durable dedupe unavailable — in-memory only: ${String(err)}`);
764
+ }
765
+ return true;
766
+ }
767
+ }
768
+
769
+ /** A swallowed FIFO wake failure, made durable (mirrors slack-front's recordWakeFailure posture:
770
+ * the console line rolls in ~1h; the audit row is the queryable signal). Never throws. */
771
+ function recordTeamsWakeFailure(key: string, err: unknown): void {
772
+ void appendAudit({
773
+ org: deploymentOrg(),
774
+ platform: "teams",
775
+ workspace: key.split("/")[1] ?? "unknown",
776
+ surface: key,
777
+ action: "surface.wake_failed",
778
+ detail: { fifoKey: key, message: String(err instanceof Error ? err.message : err).slice(0, 500) },
779
+ }).catch(() => undefined);
780
+ }
781
+
782
+ /** The live dispatch for one owned activity: per-message trust off the SAME auth capture the
783
+ * session binds with, then the teams-supervisor core over the live registry deps.
784
+ *
785
+ * `probeFor` is the roster test seam — see {@link teamsAuthWithUserType}. */
786
+ export async function liveTeamsDispatch(
787
+ a: TeamsMessageActivity,
788
+ plan: TeamsFrontPlan,
789
+ to: RouteHandlerArgs["to"],
790
+ probeFor?: TeamsRosterProbeFor,
791
+ ): Promise<void> {
792
+ // OPE-493: the roster verdict, resolved ONCE and folded into the capture that everything below
793
+ // reads — the per-message trust, the author-refutation leg and the auth the session binds with.
794
+ // Fail-open: no verdict leaves `auth` byte-identical to `plan.auth`.
795
+ const auth = await teamsAuthWithUserType(plan.auth, a, probeFor);
796
+ const wakeCtx: BindableContext = { channel: { kind: "teams" }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } };
797
+ // isBot:false is safe here: the front's author gate already dropped self/bot-authored
798
+ // activities, so a dispatched trigger is a positively-human author (the Slack analog drops
799
+ // authorIsBot in shouldSupervisorWake).
800
+ const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
801
+ let engagementTier: ReturnType<typeof engagementTierFor> | undefined;
802
+ try {
803
+ engagementTier = engagementTierFor(plan.surfaceKey, getConfig().loadDomains());
804
+ } catch {
805
+ engagementTier = undefined; // fail-soft to neutral, same as every other surface
806
+ }
807
+ // 🔴 THE GRAPH TEAM ID, NOT THE ACTIVITY'S OWN (OPE-1808). `a.teamId` is the BOT CONNECTOR's team
808
+ // id (`19:…@thread.tacv2`); every Graph URL the catch-up builds takes the team's Microsoft 365
809
+ // GROUP id instead. Resolved through the Connector and cached per team — see
810
+ // channels/teams-graph-team-id.ts. Unresolvable ⇒ absent, and the dispatch then STATES that it
811
+ // cannot read this surface's history rather than reading an empty window.
812
+ const graphTeamId = await teamsGraphTeamId(a.teamId, a.serviceUrl);
813
+ const replyChainRootId = teamsReplyChainRoot(a.conversation.id);
814
+ // 🔴 ONE EXPRESSION, READ TWICE (OPE-1747): the taint join below and the dispatch's `ingested`
815
+ // carriage, which is what the TAIL ROW records. Re-deriving it at the second reader would be a
816
+ // second opinion that can disagree with the first about one author.
817
+ const triggerFacts: IngestedChatMessage = { trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) };
818
+ await handleTeamsSingleWindowDispatch(
819
+ {
820
+ surfaceKey: plan.surfaceKey,
821
+ // OPE-1528 — the wake row's `container` is the CONVERSATION grain, the same key the post that
822
+ // answers it files under (`partition.surfaceKey` → session-tail-record.ts `tailWindowFor`).
823
+ // `plan.surfaceKey` is the DOMAIN grain and belongs to the gate, the registry row and the
824
+ // audit surface, never to the ring.
825
+ containerKey: plan.partition.surfaceKey,
826
+ // 🔴 THE CHANNEL GRAIN, NEVER THE REPLY CHAIN (OPE-1817). Graph addresses a history read as
827
+ // `/teams/{team}/channels/{channel}`, and a CHANNEL activity's `conversation.id` carries
828
+ // `;messageid=<root>` — a reply-chain id Graph has no channel by that name for, so the
829
+ // unstripped fallback was a 404 the catch-up then read as an empty room. `teamsChannelId`
830
+ // (`channelData.channel.id`) is already at channel grain; the fallback is now stripped to it.
831
+ channelId: a.teamsChannelId ?? teamsChannelGrainConversationId(a.conversation.id),
832
+ // The TEAM the channel sits in, in GRAPH's own spelling — present only on a CHANNEL activity
833
+ // whose Connector team id resolved, and a channel activity is the only shape that has a
834
+ // Graph-readable history at all. Absent ⇒ the catch-up is skipped, never guessed.
835
+ ...(graphTeamId ? { teamId: graphTeamId } : {}),
836
+ // The activity NAMED a team and its Graph id would not resolve — a transient bound on this
837
+ // wake, which the dispatch states differently from a chat's structural one.
838
+ ...(!graphTeamId && a.teamId ? { teamAddressUnresolved: true } : {}),
839
+ // OPE-466 CARRIAGE, over the TRIGGER ONLY (this leg carries no per-message-trust backscroll).
840
+ // OPE-489: the author leg reads the POSITIVE refutation — a resolved `userRole: "guest"`.
841
+ //
842
+ // OPE-493: THE FRONT NOW RUNS THE PROBE. `teamsAuthWithUserType` above resolved the Bot
843
+ // Connector roster verdict and folded it into `auth`, so the SURFACE refutation answers on this
844
+ // path exactly as it does on the native one. The roster probe IS live — a real B2B guest returns
845
+ // `userRole: "guest"` and a tenant member returns `"user"` (measured 2026-08-15). An
846
+ // unresolved verdict is still absent, and this leg fails OPEN on absence.
847
+ //
848
+ // OPE-516/OPE-525: the OPERATOR's register (`teamsAuthorFacts`) is the SECOND source of
849
+ // refutation, and answers where the roster cannot (a guest the connector will not report, a
850
+ // tenant whose bot credential cannot read the roster). Unarmed ⇒ it contributes nothing.
851
+ auth: stampNonMemberChatAuth(auth, [triggerFacts], {
852
+ surfaceKey: plan.surfaceKey,
853
+ ...teamsRoomGroupKey(auth),
854
+ }),
855
+ // THE SAME ONE ENTRY the join above reads — carried so the dispatch's tail row records it.
856
+ ingested: [triggerFacts],
857
+ trigger: {
858
+ id: a.id,
859
+ authorId: a.from.id,
860
+ authorName: a.from.name,
861
+ // Bot-Framework activities carry no parsed timestamp on eve's shape — the arrival time
862
+ // is the honest stamp (the same "now" the envelope's current-time uses).
863
+ sentAt: new Date().toISOString(),
864
+ text: a.text || "(the message had no text)",
865
+ trust,
866
+ },
867
+ // Thread meta ⇔ the trigger sat inside an EXISTING reply chain (its replyToId); a
868
+ // top-level channel post starts its own chain and renders container-scoped, mirroring
869
+ // Slack's threadTs-presence semantics.
870
+ ...(a.replyToId ? { threadRootId: teamsThreadRootActivityId(a) ?? a.replyToId } : {}),
871
+ // 🔴 THE READ'S OWN ROOT, AND IT IS NOT `threadRootId` (OPE-1817). The two answer different
872
+ // questions and only one of them is about Graph:
873
+ // • `threadRootId` is the ENVELOPE's thread meta. It is deliberately absent on a top-level
874
+ // post, because that post renders container-scoped — Slack's semantics, kept.
875
+ // • this one is the CONTAINER THE SEAT IS BOUND TO. `plan.partition.surfaceKey` is the
876
+ // CONVERSATION grain (`bindTeams`'s `surface`), and on a channel activity that is always
877
+ // one reply chain, named by the `;messageid=<root>` suffix — present on a top-level post
878
+ // too, where it is that post's own id.
879
+ // The catch-up must read THAT chain TOO. `/channels/{c}/messages` returns the channel's
880
+ // top-level messages, which by Graph's contract carry no replies at all, so a chain that did
881
+ // carry the message came back empty (ship #206/#207). `wakeAddress` in `teams-read.ts`
882
+ // already resolves the root this way for `read_thread`; this is the same resolution, brought
883
+ // to the read the seat's own wake depends on.
884
+ ...(replyChainRootId ? { replyChainRootId } : {}),
885
+ engagementTier,
886
+ window: plan.window,
887
+ },
888
+ liveTeamsSupervisorDeps(plan, to),
889
+ );
890
+ await appendAudit({
891
+ org: plan.partition.org,
892
+ platform: "teams",
893
+ workspace: plan.partition.workspace,
894
+ surface: plan.surfaceKey,
895
+ principalId: a.from.id,
896
+ action: "teams.dispatched",
897
+ detail: { addressing: plan.window.addressing ?? "channel-watch", conversationId: a.conversation.id },
898
+ }).catch(() => undefined);
899
+ }
900
+
901
+ /**
902
+ * THE DM WINDOW DIVERT (OPE-357) — a Teams personal/group chat lands in its OWN first-class standing
903
+ * window (channels/dm-seat.ts), the Teams parity of divertDiscordDmToWindow / slack-front's
904
+ * scheduleDmWake, and the last DM-family surface to first-class (Slack: OPE-351 stages 2+4; Discord:
905
+ * OPE-356).
906
+ *
907
+ * WHAT IT FIXES. Until now a Teams DM ran on eve's native per-conversation token (teams.ts's
908
+ * onMessage): a session with no window row, and therefore NO DISPATCHABLE ADDRESS. A DM that delegated
909
+ * could not be told the answer had arrived — `resolveCallerSeat` took the OPE-306 ANCHOR arm and the
910
+ * worker posted its own report into the DM (the OPE-358 double-post). A window token IS an address, so
911
+ * the DM seat now gets the whole standing-seat apparatus for free: woken, redriven, rotated (on the DM
912
+ * thresholds — `OE_DM_SEAT_ROTATE_*`, never the supervisor's), spend-counted and audited through the
913
+ * seams every other seat already uses. The worker's direct report-back stops the moment the seat
914
+ * becomes a real window (its caller now resolves via `ownWindowSeat`, not the anchor) — no two voices
915
+ * in one DM per delegated turn.
916
+ *
917
+ * SEPARATE ADMIT SPACE, NOT A FLOOR BYPASS. This never touches `teamsSingleWindowPrivacyOk`: that floor
918
+ * refuses `scope !== "channel"` outright because a personal/group chat may never join the SHARED
919
+ * cross-surface window, and it stays exactly that strict. A DM window is a STRUCTURALLY DIFFERENT,
920
+ * non-shared address space keyed on the DM's own surface key (`getOrCreateDmSeat`), deliverable to its
921
+ * own DM and nowhere else — which is precisely why it is safe to admit a DM here without weakening the
922
+ * channel floor (the Discord `isDm`-before-the-single-window-block precedent).
923
+ *
924
+ * 🔴 IT BYPASSES THE ENGAGEMENT GATE — a DM is always answered (see {@link buildTeamsDmWake}). The
925
+ * SPEND gate is NOT bypassed: `divertToUnifiedWindow` runs its own window-door gate (channels/unified.ts
926
+ * windowSpendGate), which is exactly the choke point every Teams single-window divert already leans on
927
+ * (Teams has never front-positioned a pre-gate, unlike Discord).
928
+ *
929
+ * FAIL-CLOSED: a seat-resolution or dispatch failure PROPAGATES to the FIFO's loud catch. There is
930
+ * deliberately NO fall-back to the native send — that would answer the human from a second session with
931
+ * no window row behind it, the very topology this replaces (and the OPE-351 stranding hazard).
932
+ */
933
+ export async function liveTeamsDmDivert(
934
+ a: TeamsMessageActivity,
935
+ capturedAuth: OpenEverywhereTeamsAuth,
936
+ partition: Partition,
937
+ to: RouteHandlerArgs["to"],
938
+ probeFor?: TeamsRosterProbeFor,
939
+ ): Promise<void> {
940
+ // OPE-493: the roster verdict for the DM PARTNER, folded into the capture before anything reads it.
941
+ // A Teams DM used to bind `internal` unconditionally (teamsTrust's personal-silo branch), so a B2B
942
+ // guest DMing the bot lit the `dm` shared memory layer. A resolved guest now demotes it to
943
+ // `external`, which is what the Slack DM-partner probe has always done. Fail-open: no verdict
944
+ // leaves the capture byte-identical.
945
+ const auth = await teamsAuthWithUserType(capturedAuth, a, probeFor);
946
+ const org = partition.org;
947
+ const atIso = new Date().toISOString();
948
+ // A DM has no parent container to widen to, so the seat is keyed on the DM's OWN surface key —
949
+ // NOT domainKeyOf (which resolves a channel's parent). For a personal/group chat the mapper leaves
950
+ // domainSurface unset, so partition.surfaceKey already IS the conversation-grain key.
951
+ const surfaceKey = partition.surfaceKey;
952
+ const conversationId = a.conversation.id;
953
+
954
+ // The seat: create / rotate / resume the DM's own standing window row.
955
+ const seat = await getOrCreateDmSeat(org, { surfaceKey }, atIso);
956
+
957
+ // PER-WAKE TRUST off the SAME auth capture the session binds with; `principalFallback` because a DM
958
+ // partner is the seat's principal. `isBot:false` is safe — the front's author gate already dropped
959
+ // self/bot-authored activities before this runs.
960
+ const wakeCtx: BindableContext = {
961
+ channel: { kind: "teams" },
962
+ session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } },
963
+ };
964
+ const trust = resolveWakeTrustFromContext(wakeCtx, { id: a.from.id, isBot: false }, { principalFallback: true });
965
+ const wakeId = mintWakeId();
966
+
967
+ // The frozen reply target: a PLAIN conversation send into the DM — no `replyToActivityId` (a personal
968
+ // chat has no reply chain; a group chat's chain anchor is not a divert fact) — the shape
969
+ // teamsPostArm.dispatchUnified already posts back through (serviceUrl + conversationId, no reply
970
+ // anchor ⇒ plain send). This IS the mint now: the codec's `dmOrigin` arm that used to produce the
971
+ // same minimal origin for a DM delegation anchor went with the anchor (OPE-368).
972
+ const origin: UnifiedWakeOrigin = { platform: "teams", serviceUrl: a.serviceUrl, conversationId };
973
+
974
+ const rendered = renderWake(
975
+ buildTeamsDmWake({
976
+ conversationId,
977
+ wakeId,
978
+ ...(seat.freshGeneration && seat.tail.length > 0 ? { tail: seat.tail } : {}),
979
+ trigger: {
980
+ id: a.id || "unknown",
981
+ author: a.from.name ?? a.from.id,
982
+ authorId: a.from.id,
983
+ sentAt: atIso,
984
+ text: a.text || "(the message had no text)",
985
+ trust,
986
+ },
987
+ }),
988
+ );
989
+ await divertToUnifiedWindow(to, {
990
+ message: rendered.message,
991
+ ...(rendered.context && rendered.context.length > 0 ? { context: rendered.context } : {}),
992
+ token: seat.token,
993
+ // `vendorTs` (OPE-1716): the DM leg's own FRONT-DOOR RECEIPT, for the same reason and with the
994
+ // same limits as the channel leg's — a Bot-Framework activity carries no parsed timestamp. A DM
995
+ // wake without it would leave its ingress row with no first anchor at all.
996
+ window: { wakeId, origin, addressing: "direct-mention", ...spreadVendorTs(receiptEventTime(Date.now())) } satisfies UnifiedWindowWake,
997
+ // Byte-exactly the surface named inside the token — the self-consistency both admission gates match
998
+ // on, and the whole privacy fence (a DM window is deliverable to ITS OWN DM and nowhere else).
999
+ surfaceKey,
1000
+ // OPE-466 CARRIAGE: one ingested message, so the join runs over the trigger's own tier.
1001
+ // OPE-489: the author leg reads a positively-resolved `userRole: "guest"`, which the roster probe
1002
+ // above put on this capture (OPE-493); a DM has no room leg (`dm: true` — on a DM the trust plane
1003
+ // asks about the PARTNER, not about the room).
1004
+ auth: stampNonMemberChatAuth(
1005
+ auth as unknown as { attributes?: Record<string, unknown> },
1006
+ [{ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }],
1007
+ { dm: true },
1008
+ ),
1009
+ });
1010
+ // Record the wake on the seat's own row (turnCount/lastWakeAt feed the rotation thresholds),
1011
+ // best-effort — a touch failure must never affect the reply.
1012
+ await touchDmSeat(org, surfaceKey, atIso).catch(() => undefined);
1013
+ // OPE-794 — file this DM wake on the live seat generation's tail ring, AFTER it landed, and only
1014
+ // the TRIGGER. The registry key is the DM window key, the same row the seat itself lives in.
1015
+ await recordWakeTail(org, dmWindowRegistryKey(surfaceKey), seat.token, {
1016
+ kind: "wake",
1017
+ at: atIso,
1018
+ from: "human", // the front's author gate already dropped self/bot-authored activities
1019
+ trust,
1020
+ // THE AUTHOR GRAIN (OPE-1747) — the same triple the DM's `stampNonMemberChatAuth` join above
1021
+ // reads: the resolved tier, Entra's `userType: "guest"` refutation, and the operator's register.
1022
+ ...tailAuthorGrain({ trust, refuted: teamsAuthorRefuted(auth), ...teamsAuthorFacts(auth, a.from.id) }),
1023
+ author: a.from.name ?? a.from.id,
1024
+ authorId: a.from.id,
1025
+ id: a.id || "unknown",
1026
+ platform: "teams",
1027
+ container: surfaceKey,
1028
+ text: a.text ?? "",
1029
+ });
1030
+ await appendAudit({
1031
+ org,
1032
+ platform: "teams",
1033
+ workspace: partition.workspace,
1034
+ surface: surfaceKey,
1035
+ principalId: a.from.id,
1036
+ action: "teams.dispatched",
1037
+ detail: { addressing: "direct-mention", dmWindow: true, conversationId },
1038
+ }).catch(() => undefined);
1039
+ }
1040
+
1041
+ /**
1042
+ * Build the front route handler. Verifies (fail-closed 401), classifies, then forwards or
1043
+ * diverts. `innerHandler` is teamsChannel's own POST handler, invoked with a FRESH Request
1044
+ * (re-readable body) + the same {@link RouteHandlerArgs}, so teamsChannel re-verifies and
1045
+ * dispatches exactly as if it owned the URL — byte-identical whenever no plan resolves.
1046
+ */
1047
+ export function buildTeamsFrontHandler(innerHandler: RouteHandler, opts: TeamsFrontOptions = {}): RouteHandler {
1048
+ // TODO(teams-front-credentials): the default verify uses env app id only (`{}`). If the wrapped
1049
+ // native teamsChannel is ever built with `credentials.appId` or a custom webhookVerifier, the
1050
+ // front would verify with a DIFFERENT config than the inner re-verify (front could 401 a request
1051
+ // the inner would accept, or vice-versa). Harmless today (teams.ts passes no credentials, so both
1052
+ // sides use the env app id), but if teams.ts starts passing credentials, thread them here via
1053
+ // opts.verify. Needs eve's Channel to expose the inner verifier config to plumb cleanly.
1054
+ const verify = opts.verify ?? ((req: Request) => verifyTeamsRequest(req, {}));
1055
+ const dedupe = opts.dedupe ?? new EventDedupe();
1056
+ const fifo = opts.fifo ?? new ThreadFifo(recordTeamsWakeFailure);
1057
+ const now = opts.now ?? (() => Date.now());
1058
+ const markEventSeen = opts.markEventSeen ?? liveMarkEventSeen;
1059
+ const planFor = opts.planFor ?? liveTeamsFrontPlan;
1060
+ const dispatch = opts.dispatch ?? liveTeamsDispatch;
1061
+ const dmDivert = opts.dmDivert ?? liveTeamsDmDivert;
1062
+ const externalGate = opts.externalGate ?? liveTeamsExternalGate;
1063
+ const stopReaction = opts.stopReaction ?? ((rx: TeamsReactionActivity, attachSession: AttachSessionForCancel) => routeTeamsStopReaction(rx, { attachSession }));
1064
+ const engagementSeam = opts.engagementSeam ?? resolveTeamsEngagement;
1065
+ const markEngaged = opts.markEngaged ?? ((a: TeamsMessageActivity) => markTeamsEngaged(a));
1066
+ const roomStopped = opts.roomStopped ?? teamsRoomWatchStopped;
1067
+ const markRoomWatched = opts.markRoomWatched ?? markTeamsRoomWatched;
1068
+ const clearRoomStop = opts.clearRoomStop ?? clearTeamsRoomStop;
1069
+
1070
+ return async (req: Request, args: RouteHandlerArgs): Promise<Response> => {
1071
+ let rawBody: string;
1072
+ try {
1073
+ rawBody = await verify(req);
1074
+ } catch {
1075
+ return new Response("unauthorized", { status: 401 });
1076
+ }
1077
+ const forward = (): Promise<Response> => {
1078
+ const forwarded = new Request(req.url, { method: "POST", headers: req.headers, body: rawBody });
1079
+ return innerHandler(forwarded, args);
1080
+ };
1081
+
1082
+ let parsed: unknown;
1083
+ try {
1084
+ parsed = JSON.parse(rawBody);
1085
+ } catch {
1086
+ return forward(); // unparseable — let teamsChannel ack it exactly as today
1087
+ }
1088
+ // ── THE STOP REACTION (OPE-898) ──────────────────────────────────────────────────────────────
1089
+ //
1090
+ // POSITION IS LOAD-BEARING. It sits ABOVE `parseTeamsActivity`, because that parser answers only
1091
+ // for `message` activities — a `messageReaction` comes back as null and would fall straight into
1092
+ // the forward below, which is why this affordance did nothing before. It is also the whole point
1093
+ // that this runs in the INGRESS: `cancelAnchoredTurn` mints no session and calls no model, so a
1094
+ // stop stays free and keeps working for a scope that is already over its spend cap.
1095
+ //
1096
+ // ACK, NEVER FORWARD. The native channel has nothing to do with a reaction activity, and the arm
1097
+ // fails closed on every unknown — an unrecognised body simply is not a reaction and returns null.
1098
+ const reaction = parseTeamsReaction(parsed);
1099
+ if (reaction) {
1100
+ args.waitUntil(
1101
+ stopReaction(reaction, args.attachSession).then(
1102
+ (o) => tlog(`reaction in ${reaction.conversationId ?? "?"}: ${o.stopped ? o.outcome : o.reason}`),
1103
+ (err) => tlog(`stop leg failed: ${String(err)}`),
1104
+ ),
1105
+ );
1106
+ return OK();
1107
+ }
1108
+
1109
+ const activity = parseTeamsActivity(parsed);
1110
+ if (!activity || activity.type !== "message") {
1111
+ return forward(); // invokes / conversationUpdates / unknown — the native channel owns them
1112
+ }
1113
+
1114
+ // The author gate, resolved ONCE for every branch below: a self/bot-authored activity never
1115
+ // wakes anything here. It is also the reason the policy gate below skips them — a machine author
1116
+ // is below peer standing but it is not a person outside the organisation (the `relay` ruling in
1117
+ // access/external-policy.ts), so it has no author-grain verdict to receive.
1118
+ // ONE PREDICATE, SHARED WITH THE SEAM (channels/teams-engagement.ts). The native gate needs the
1119
+ // same answer — a machine-authored CHANNEL activity is still forwarded to it — and two spellings
1120
+ // of "is this a bot" is how one of them comes to miss a case the other catches.
1121
+ const machineAuthored = teamsMachineAuthored(activity);
1122
+
1123
+ // ── THE EXTERNAL-PARTICIPANT POLICY (OPE-507 author grain, OPE-504 room grain) ────────────────
1124
+ //
1125
+ // POSITION IS LOAD-BEARING, and for TWO reasons on Teams. It sits above the DM divert and above
1126
+ // `planFor`, so (a) an activity the agent may not act on costs nothing — no seat resolution, no
1127
+ // session mint, no spend read — and (b) a SUPPRESSED activity is never FORWARDED, which is what
1128
+ // extends the policy to a deployment running the native path with the window gates off.
1129
+ //
1130
+ // Both grains are inert under their shipped values (`taint` / `engage`), so this is a no-op on
1131
+ // every deployment that has not opted in.
1132
+ if (!machineAuthored && (await externalGate(activity))) return OK();
1133
+
1134
+ // ── CAPTURE THE CONVERSATION REFERENCE (OPE-887) ─────────────────────────────────────────────
1135
+ //
1136
+ // THE ONE PLACE A PROACTIVE TEAMS ADDRESS EVER COMES FROM. A Bot Framework send needs a regional
1137
+ // `serviceUrl` and a Microsoft-minted conversation id, and neither exists anywhere but on an
1138
+ // inbound activity — so an activity that passes here without being recorded is an address this
1139
+ // deployment can never hold again. Both Teams arms that address a person or a team with no live
1140
+ // turn (the DM arm, the account directory) read what this writes.
1141
+ //
1142
+ // POSITION IS LOAD-BEARING, in both directions. It sits BELOW the external-participant gate, so a
1143
+ // suppressed author never contributes an address the agent could later message; and it sits ABOVE
1144
+ // the DM divert and `planFor`, so it captures EVERY activity this deployment acts on — including
1145
+ // the ones that fall through to the native path when the window gates are off, which no branch
1146
+ // below would have covered.
1147
+ //
1148
+ // AFTER THE ACK, and fail-soft inside: `recordTeamsConversationReference` never throws, and a lost
1149
+ // capture costs a future delivery, never this wake. Machine-authored activities record nothing —
1150
+ // there is no person or team to reach behind a bot.
1151
+ if (!machineAuthored) {
1152
+ args.waitUntil(
1153
+ recordTeamsConversationReference({
1154
+ scope: activity.scope,
1155
+ tenantId: activity.tenantId ?? activity.conversation.tenantId,
1156
+ conversationId: activity.conversation.id,
1157
+ serviceUrl: activity.serviceUrl,
1158
+ userId: activity.from.id,
1159
+ teamId: activity.teamId,
1160
+ }),
1161
+ );
1162
+ }
1163
+
1164
+ // ── LEG E: THE AMBIENT ENGAGEMENT SEAM (OPE-1500, contract §2.5) ─────────────────────────────
1165
+ //
1166
+ // The middle posture, and the one Teams had no form of: an @mention marks the conversation
1167
+ // ENGAGED, a later PLAIN message in it wakes the agent, and `stand_down` stores the stop. The
1168
+ // policy is core's (`channels/engagement.ts` — `markThreadEngaged` / `shouldDeliverReply` / the
1169
+ // durable `stopped` flag); `channels/teams-engagement.ts` translates Teams facts into it.
1170
+ //
1171
+ // POSITION IS LOAD-BEARING, and it is what keeps LEG A AND LEG E FROM DOUBLE-HANDLING ONE
1172
+ // MESSAGE. This block runs ABOVE both the DM divert and `planFor`, and it dispatches NOTHING of
1173
+ // its own. It contributes exactly two decisions, and every wake below stays the property of the
1174
+ // leg that already owned it:
1175
+ // • THE MARK, on a mention, on EVERY path — including a single-window channel, where Leg A
1176
+ // answers the mention. Without the mark on that path `stand_down` would have no row to flag
1177
+ // and would refuse on a surface the verb is supposed to work on.
1178
+ // • THE SUPPRESSION, on a stood-down conversation, ABOVE EVERYTHING — never diverted, never
1179
+ // forwarded, so both legs go quiet. This is the half §7.6 of the contract records as
1180
+ // missing: Leg A's ambient wake reads no registry of its own.
1181
+ //
1182
+ // 🔴 WHAT THIS SUPPRESSION COVERS, AND WHERE THE OTHER HALF LIVES. The `stopped` flag read here
1183
+ // sits on a CONVERSATION row, and only an @mention writes one — so this block closes exactly the
1184
+ // case where somebody invited the agent into a conversation and then asked it to stop. Leg A's
1185
+ // UNDIRECTED ambient wake is a room-grain wake with no conversation row behind it, so it has its
1186
+ // own room-grain stop further down this handler (OPE-1511, search LEG A'S OWN STOP).
1187
+ //
1188
+ // THE TWO ARE NOT INDEPENDENT, and the overlap runs one way. A ROOM stop suppresses an ENGAGED
1189
+ // conversation's ambient follow-up too: that activity resolves `deliver` here, falls through
1190
+ // this block, and is dropped by the room gate below. So a stop asked for in one reply chain
1191
+ // silences a chain whose people did invite the agent. It is the honest reading of a room-grain
1192
+ // ask — `stand_down`'s room copy says "anywhere in this room" — and it errs toward silence,
1193
+ // which is the safe direction for a wake gate. It does NOT run the other way: a CONVERSATION
1194
+ // stop leaves the room row alone, so the rest of the room keeps waking.
1195
+ //
1196
+ // THE DELIVERY HALF IS NOT DECIDED HERE, and deliberately so. The front holds no primitive that
1197
+ // could wake an existing native session, so admitting the activity through the NATIVE mention
1198
+ // gate IS the wake (channels/teams.ts, which asks the same seam). A `deliver` AND a plain `hold`
1199
+ // therefore both fall through to the ordinary forward, and that gate answers. The cost is one
1200
+ // extra registry read on a forwarded non-mention message; the alternative — dropping a `hold`
1201
+ // here — would skip everything eve's own channel does with an activity before `onMessage`, for
1202
+ // no change in outcome. A read is the cheaper mistake to be wrong about.
1203
+ //
1204
+ // Machine-authored activities skip the seam entirely: they wake nothing on any path, so the
1205
+ // registry read would be paid for a message with no decision behind it.
1206
+ const seam: TeamsEngagementVerdict | null = machineAuthored ? null : await engagementSeam(activity, machineAuthored);
1207
+ if (seam?.kind === "engage") {
1208
+ // 🔴 THE MARK IS DEDUPED, AND THE STOP IS WHY. `markThreadEngaged({ participating: true })` is
1209
+ // the re-invitation, so it CLEARS a `stopped` flag — correct for a person's second @mention,
1210
+ // wrong for the Bot Framework REDELIVERING the first one. Redelivery is routine here (a slow
1211
+ // ack is enough, and this mark is itself awaited before the ack). Both layers, exactly as the
1212
+ // wake dedupe below uses them, under a SEPARATE key prefix so neither consumes the other's
1213
+ // first sighting. Fail-open on a store error, same posture as that dedupe.
1214
+ //
1215
+ // 🔴 THIS IS THE CHEAP LAYER, NOT THE GUARANTEE (OPE-1527). Both layers EXPIRE — the in-memory
1216
+ // one with the process, the durable one after DEDUPE_TTL_MS — and a stand-down does not expire
1217
+ // at all, so a redelivery on a fresh process after the TTL sailed through here and lifted the
1218
+ // stop. What actually holds is in the store: the promotion carries the activity id as
1219
+ // `engagedBy`, and the row refuses to clear `stopped` for an id it already recorded
1220
+ // (channels/teams-engagement.ts `markTeamsEngaged`). This layer stays because it saves the
1221
+ // round-trip on the common retry, never because it bounds the replay.
1222
+ const engageKey = `teams-engage:${activity.conversation.id}:${activity.id}`;
1223
+ if (!dedupe.seen(engageKey, now())) {
1224
+ dedupe.mark(engageKey, now());
1225
+ // AWAITED, not deferred: the mark must land before the next message in this conversation is
1226
+ // decided against it, and `markThreadEngaged` already swallows a store failure.
1227
+ if (await markEventSeen(seam.org, engageKey, new Date(now()).toISOString())) await markEngaged(activity);
1228
+ }
1229
+ }
1230
+ if (seam?.kind === "hold" && seam.stopped) {
1231
+ // 🔴 A PARKED QUESTION'S ANSWER IS NEVER SUPPRESSED. eve routes a text HITL reply as an
1232
+ // ordinary `message` activity and branches on it BEFORE `onMessage`, so a blanket early return
1233
+ // here would silently drop the answer to a question the agent itself asked — the exact
1234
+ // silent-HITL-drop shape channels/teams-input-response.ts exists to prevent (OPE-574). Standing
1235
+ // down means "stop following the room", never "stop listening to what I asked you".
1236
+ if (isTeamsInputResponseActivity(activity)) {
1237
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down, but this is an answer to a parked question — forwarded`);
1238
+ return forward();
1239
+ }
1240
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: stood down here — suppressed on BOTH legs until an @mention`);
1241
+ return OK();
1242
+ }
1243
+
1244
+ // FIRST-CLASS DM WINDOW DIVERT (OPE-357). A personal (1:1) or group chat is diverted into its OWN
1245
+ // dedicated DM window — a STRUCTURALLY SEPARATE admit space from the shared channel window. This
1246
+ // sits BEFORE `planFor` deliberately: the single-window privacy floor (teamsSingleWindowPrivacyOk)
1247
+ // refuses `scope !== "channel"` UNCONDITIONALLY and must stay that strict; a DM window never joins
1248
+ // the shared one, so bypassing that floor here is correct, not a weakening (the Discord
1249
+ // `isDm`-before-the-single-window-block precedent). It is UNCONDITIONAL since OPE-368: the
1250
+ // `OE_DM_SEAT_ROTATION=0` hatch that used to sit in this condition (falling through to planFor's
1251
+ // floor, which forwards the DM to the native path) was the last supported way to run a Teams DM on
1252
+ // an un-wakeable native token — the seat class the retired OPE-306 delegation anchor served.
1253
+ if (activity.scope === "personal" || activity.scope === "groupChat") {
1254
+ // Author gate (mirrors the channel branch below): a machine-authored DM activity never wakes a
1255
+ // seat — dropped, not forwarded (a DM window owns this surface; the native path is retired here).
1256
+ if (machineAuthored) return OK();
1257
+ // 🔴 LEG E GATES A GROUP CHAT, AND NEVER A 1:1 (OPE-1500). A personal chat resolves
1258
+ // `unanchored`, so this line cannot fire on one: "a DM is always answered" is unchanged.
1259
+ //
1260
+ // A GROUP CHAT is different, and this narrows what it used to do. The `ChatMessage.Read.Chat`
1261
+ // RSC grant makes Microsoft deliver EVERY group-chat message here, and this divert is
1262
+ // unconditional (OPE-368) — so a granted tenant ran a full agent session on every message any
1263
+ // member typed. The native mention gate (channels/teams.ts) never covered it: a group chat is
1264
+ // diverted here and never forwarded. The seam is the posture that belongs in between — the
1265
+ // chat is answered once it @mentions the agent, and goes quiet again on `stand_down`.
1266
+ //
1267
+ // FAIL-CLOSED, and that is a real second narrowing: `getThreadEngagement` reads a store failure
1268
+ // as "no row" (channels/engagement.ts), so an operational-store outage holds every group chat
1269
+ // rather than answering it. That is the right direction for a wake gate — an unreadable
1270
+ // registry cannot prove the agent was invited — but it means a chat can go quiet for a reason
1271
+ // nobody in it said out loud.
1272
+ if (seam?.kind === "hold") {
1273
+ // 🔴 THE SAME PARKED-QUESTION CARVE-OUT THE STOOD-DOWN BRANCH CARRIES, and this branch needs it
1274
+ // for a WIDER set of holds: a group chat holds whenever it is un-engaged, which includes the
1275
+ // fail-closed hold a store outage produces. A routine can park a question in a chat that holds
1276
+ // for either reason, and dropping the person's answer here would be the silent-HITL-drop shape
1277
+ // channels/teams-input-response.ts exists to prevent (OPE-574). Forwarded, so eve's own HITL
1278
+ // path answers it.
1279
+ if (isTeamsInputResponseActivity(activity)) {
1280
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged, but this is an answer to a parked question — forwarded`);
1281
+ return forward();
1282
+ }
1283
+ tlog(`activity ${activity.id} in ${activity.conversation.id}: group chat not engaged — ${seam.reason}`);
1284
+ return OK();
1285
+ }
1286
+ // Two-layer idempotency, same as the channel path: an in-memory guard first (no org needed), then
1287
+ // the durable store once the partition is resolved. A Bot-Framework redelivery must never
1288
+ // double-wake; fail-open on a store error (dedupe prevents a double reply, not a security hole).
1289
+ const dmKey = `teams:${activity.conversation.id}:${activity.id}`;
1290
+ if (dedupe.seen(dmKey, now())) return OK();
1291
+ dedupe.mark(dmKey, now());
1292
+ // Resolve the DM's identity ONCE (auth + partition) — fail-closed to the native path on any
1293
+ // binding/partition error, exactly as liveTeamsFrontPlan's own try/catch does.
1294
+ let dm: { auth: OpenEverywhereTeamsAuth; partition: Partition } | null;
1295
+ try {
1296
+ const auth = authForActivity(activity);
1297
+ const partition = resolvePartition({ channel: { kind: "teams" }, session: { auth: { current: auth } } });
1298
+ dm = { auth, partition };
1299
+ } catch {
1300
+ dm = null;
1301
+ }
1302
+ if (!dm) return forward(); // can't anchor the DM to an org ⇒ let the native path own it
1303
+ const { auth: dmAuth, partition: dmPartition } = dm; // const-capture for the deferred FIFO closure
1304
+ if (!(await markEventSeen(dmPartition.org, dmKey, new Date(now()).toISOString()))) return OK();
1305
+ tlog(`activity ${activity.id} on ${dmPartition.surfaceKey}: DM — diverting to its own first-class DM window`);
1306
+ // Per-container FIFO (the same discipline as the channel path): one DM's wakes deliver strictly in
1307
+ // arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
1308
+ args.waitUntil(fifo.enqueue(dmPartition.surfaceKey, () => dmDivert(activity, dmAuth, dmPartition, args.to)));
1309
+ return OK();
1310
+ }
1311
+
1312
+ // THE one single-window decision (single-leg: one webhook, one synchronous verdict). No plan
1313
+ // — gates off, floor failed, config/partition error — ⇒ forward, byte-identical to today.
1314
+ const plan = planFor(activity);
1315
+ if (!plan) return forward();
1316
+
1317
+ // Author gate (mirrors Slack's authorIsBot disqualifier on a kept mention): the window owns
1318
+ // this surface's traffic, and machine-authored activities never wake the shared session —
1319
+ // dropped, not forwarded (forwarding would hand a suppressed surface back to the native
1320
+ // session for bots only, splitting the context the window exists to unify).
1321
+ if (machineAuthored) {
1322
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: bot/self-authored — dropped (single-window surface, relay is Phase 2)`);
1323
+ return OK();
1324
+ }
1325
+
1326
+ // ── LEG A'S OWN STOP (OPE-1511) ─────────────────────────────────────────────────────────────
1327
+ //
1328
+ // The half the block above could not cover, and now does. Leg A wakes on ANY message in a
1329
+ // vouched single-window channel, including a reply chain nobody ever @mentioned the agent in.
1330
+ // A `stopped` flag lives on an engagement row and only an @mention wrote one, so a person in
1331
+ // such a room could not stop the wake and `stand_down` refused for want of a row. The rule is
1332
+ // that a stored stop exists exactly where a stored wake exists: this leg now stores a ROOM-grain
1333
+ // row on every ambient wake, reads that row's flag here, and `tools/stand_down.ts` marks it when
1334
+ // the conversation grain holds nothing (channels/teams-engagement.ts carries the whole shape).
1335
+ //
1336
+ // AN @MENTION IS THE WAY BACK, and it must run BEFORE the gate or the stop would be a trap: the
1337
+ // mention re-invites the ROOM, and Leg E's own mark re-invites only the conversation. The clear
1338
+ // carries the activity id, so a redelivery of the mention the row records lifts nothing
1339
+ // (OPE-1527; one slot — OPE-1562).
1340
+ // A FAILED clear leaves the room stopped and still answers this mention — the person's next
1341
+ // @mention retries it, which is the safe direction for a write that only ever removes a stop.
1342
+ //
1343
+ // 🔴 NO `.catch()` ON THE GATE, and that asymmetry is deliberate. `teamsRoomWatchStopped` owns
1344
+ // its own failure direction and picks FAIL-CLOSED (hold the wake, its doc says why). A swallow
1345
+ // here would silently invert that to fail-open and hand a stood-down room back to the ambient
1346
+ // leg — the exact defect the read was written to prevent. It never throws.
1347
+ const addressedHere = plan.window.addressing === "direct-mention";
1348
+ if (addressedHere) {
1349
+ await clearRoomStop(plan.partition, new Date(now()).toISOString(), activity.id).catch(() => undefined);
1350
+ } else if (await roomStopped(plan.partition)) {
1351
+ // 🔴 THE SAME PARKED-QUESTION CARVE-OUT the stood-down conversation branch carries. eve routes
1352
+ // a text HITL answer as an ordinary message activity, so dropping one here would silently lose
1353
+ // the answer to a question the agent itself asked (OPE-574).
1354
+ if (isTeamsInputResponseActivity(activity)) {
1355
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: room stood down, but this is an answer to a parked question — forwarded`);
1356
+ return forward();
1357
+ }
1358
+ tlog(`activity ${activity.id} on ${plan.surfaceKey}: this room was stood down — ambient wake suppressed until an @mention`);
1359
+ return OK();
1360
+ }
1361
+
1362
+ // Two-layer idempotency (in-memory + the durable cross-instance store): a Bot-Framework
1363
+ // redelivery (non-200 → retry, same activity id) must never double-wake. Fail-open on a
1364
+ // store error — dedupe prevents a double reply, not a security hole.
1365
+ const key = `teams:${activity.conversation.id}:${activity.id}`;
1366
+ if (dedupe.seen(key, now())) return OK();
1367
+ dedupe.mark(key, now());
1368
+ if (!(await markEventSeen(plan.partition.org, key, new Date(now()).toISOString()))) return OK();
1369
+
1370
+ tlog(
1371
+ `activity ${activity.id} on ${plan.surfaceKey}: single-window — diverting to the unified supervisor ` +
1372
+ `(${plan.window.addressing === "direct-mention" ? "addressed @mention; native forward suppressed" : "ambient channel-watch"})`,
1373
+ );
1374
+ // THE STORED WAKE THE STORED STOP NEEDS (OPE-1511). Written for the AMBIENT leg only: an
1375
+ // addressed @mention already engages its conversation through Leg E, and it has just re-invited
1376
+ // the room above. Deferred, because it decides nothing about this wake — a lost row costs a
1377
+ // later stop somewhere to land, never a turn.
1378
+ if (!addressedHere) args.waitUntil(markRoomWatched(plan.partition, new Date(now()).toISOString()).catch(() => undefined));
1379
+ // Per-container FIFO (the slack-front supervisor-key discipline): one channel's wakes deliver
1380
+ // strictly in arrival order; a failure lands in the FIFO's loud catch + a durable audit row.
1381
+ args.waitUntil(fifo.enqueue(plan.surfaceKey, () => dispatch(activity, plan, args.to)));
1382
+ return OK();
1383
+ };
1384
+ }
1385
+
1386
+ /**
1387
+ * Compose the front route with a wrapped `teamsChannel`. `inner` must have been built with
1388
+ * `route: INTERNAL_TEAMS_ROUTE`. Returns the SAME channel value (preserving eve's internal
1389
+ * adapter/receive) with the public front route prepended — so the Bot Framework's URL hits us,
1390
+ * and both routes share one channel name → one session namespace (the frontSlackChannel shape).
1391
+ */
1392
+ export function frontTeamsChannel<C extends Channel<any, any, any>>(inner: C, opts: TeamsFrontOptions = {}): C {
1393
+ const innerPost = inner.routes.find(
1394
+ (r): r is HttpRouteDefinition<any> => r.method === "POST" && r.path === INTERNAL_TEAMS_ROUTE,
1395
+ );
1396
+ if (!innerPost) {
1397
+ throw new Error(
1398
+ `frontTeamsChannel: wrapped teamsChannel must be built with route: "${INTERNAL_TEAMS_ROUTE}" ` +
1399
+ `(found paths: ${inner.routes.map((r) => r.path).join(", ")})`,
1400
+ );
1401
+ }
1402
+ const front = buildTeamsFrontHandler(innerPost.handler, opts);
1403
+ return { ...inner, routes: [POST(PUBLIC_TEAMS_ROUTE, front), ...inner.routes] };
1404
+ }